Экспериментальный код
Вот экспериментальный код, который может запускать указанное количество рабочих процессов, а затем запускать указанное количество рабочих потоков в каждом процессе и выполнять задачу выборки URL-адресов:
import multiprocessing
import sys
import time
import threading
import urllib.request
def main():
processes = int(sys.argv[1])
threads = int(sys.argv[2])
urls = int(sys.argv[3])
# Start process workers.
in_q = multiprocessing.Queue()
process_workers = []
for _ in range(processes):
w = multiprocessing.Process(target=process_worker, args=(threads, in_q))
w.start()
process_workers.append(w)
start_time = time.time()
# Feed work.
for n in range(urls):
in_q.put('http://www.example.com/?n={}'.format(n))
# Send sentinel for each thread worker to quit.
for _ in range(processes * threads):
in_q.put(None)
# Wait for workers to terminate.
for w in process_workers:
w.join()
# Print time consumed and fetch speed.
total_time = time.time() - start_time
fetch_speed = urls / total_time
print('{} x {} workers => {:.3} s, {:.1f} URLs/s'
.format(processes, threads, total_time, fetch_speed))
def process_worker(threads, in_q):
# Start thread workers.
thread_workers = []
for _ in range(threads):
w = threading.Thread(target=thread_worker, args=(in_q,))
w.start()
thread_workers.append(w)
# Wait for thread workers to terminate.
for w in thread_workers:
w.join()
def thread_worker(in_q):
# Each thread performs the actual work. In this case, we will assume
# that the work is to fetch a given URL.
while True:
url = in_q.get()
if url is None:
break
with urllib.request.urlopen(url) as u:
pass # Do nothing
# print('{} - {} {}'.format(url, u.getcode(), u.reason))
if __name__ == '__main__':
main()
Вот как я запускаю эту программу:
python3 foo.py <PROCESSES> <THREADS> <URLS>
Например, python3 foo.py 20 20 10000
создает 20 рабочих процессов с 20 потоками в каждом рабочем процессе (таким образом, всего 400 рабочих потоков) и получает 10000 URL-адресов. В конце эта программа печатает, сколько времени потребовалось для получения URL-адресов и сколько URL-адресов было получено в среднем в секунду.
Обратите внимание, что во всех случаях я действительно нажимаю URL-адрес домена www.example.com
, т. Е. www.example.com
- это не просто заполнитель. Другими словами, я запускаю приведенный выше код без изменений.
Окружающая среда
Я тестирую этот код на виртуальном частном сервере Linode, который имеет 8 ГБ ОЗУ и 4 ЦП. Он работает под управлением Debian 9.
$ cat /etc/debian_version
9.9
$ python3
Python 3.5.3 (default, Sep 27 2018, 17:25:39)
[GCC 6.3.0 20170516] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
$ free -m
total used free shared buff/cache available
Mem: 7987 67 7834 10 85 7734
Swap: 511 0 511
$ nproc
4
Случай 1: 20 процессов x 20 потоков
Вот несколько пробных запусков с 400 рабочими потоками, распределенными между 20 рабочими процессами (т.е. 20 рабочих потоков в каждом из 20 рабочих процессов). В каждом испытании выбирается 10 000 URL.
Вот результаты:
$ python3 foo.py 20 20 10000
20 x 20 workers => 5.12 s, 1954.6 URLs/s
$ python3 foo.py 20 20 10000
20 x 20 workers => 5.28 s, 1895.5 URLs/s
$ python3 foo.py 20 20 10000
20 x 20 workers => 5.22 s, 1914.2 URLs/s
$ python3 foo.py 20 20 10000
20 x 20 workers => 5.38 s, 1859.8 URLs/s
$ python3 foo.py 20 20 10000
20 x 20 workers => 5.19 s, 1925.2 URLs/s
Мы видим, что в среднем получается около 1900 URL-адресов в секунду. Когда я отслеживаю использование ЦП с помощью команды top
, я вижу, что каждый рабочий процесс python3
потребляет от 10% до 15% ЦП.
Случай 2: 4 процесса x 100 потоков
Теперь я думал, что у меня только 4 процессора. Даже если я запускаю 20 рабочих процессов, в любой момент времени физически может работать не более 4 процессов. Кроме того, из-за глобальной блокировки интерпретатора (GIL) только один поток в каждом процессе (таким образом, максимум 4 потока) может работать в любой момент физического времени.
Поэтому я подумал, что если я уменьшу количество процессов до 4 и увеличу количество потоков на процесс до 100, чтобы общее число потоков оставалось равным 400, производительность не должна ухудшаться.
Но результаты теста показывают, что 4 процесса, содержащие по 100 потоков, последовательно работают хуже, чем 20 процессов, содержащих по 20 потоков каждый.
$ python3 foo.py 4 100 10000
4 x 100 workers => 9.2 s, 1086.4 URLs/s
$ python3 foo.py 4 100 10000
4 x 100 workers => 10.9 s, 916.5 URLs/s
$ python3 foo.py 4 100 10000
4 x 100 workers => 7.8 s, 1282.2 URLs/s
$ python3 foo.py 4 100 10000
4 x 100 workers => 10.3 s, 972.3 URLs/s
$ python3 foo.py 4 100 10000
4 x 100 workers => 6.37 s, 1570.9 URLs/s
Загрузка ЦП составляет от 40% до 60% для каждого python3
рабочего процесса.
Случай 3: 1 процесс x 400 потоков
Просто для сравнения, я фиксирую тот факт, что и случай 1, и случай 2 превосходят тот случай, когда все 400 потоков выполняются в одном процессе. Это, безусловно, связано с глобальной блокировкой интерпретатора (GIL).
$ python3 foo.py 1 400 10000
1 x 400 workers => 13.5 s, 742.8 URLs/s
$ python3 foo.py 1 400 10000
1 x 400 workers => 14.3 s, 697.5 URLs/s
$ python3 foo.py 1 400 10000
1 x 400 workers => 13.1 s, 761.3 URLs/s
$ python3 foo.py 1 400 10000
1 x 400 workers => 15.6 s, 640.4 URLs/s
$ python3 foo.py 1 400 10000
1 x 400 workers => 13.1 s, 764.4 URLs/s
Загрузка ЦП составляет от 120% до 125% для одного python3
рабочего процесса.
Случай 4: 400 процессов x 1 поток
Опять же, просто для сравнения, вот как выглядят результаты при 400 процессах, каждый с одним потоком.
$ python3 foo.py 400 1 10000
400 x 1 workers => 14.0 s, 715.0 URLs/s
$ python3 foo.py 400 1 10000
400 x 1 workers => 6.1 s, 1638.9 URLs/s
$ python3 foo.py 400 1 10000
400 x 1 workers => 7.08 s, 1413.1 URLs/s
$ python3 foo.py 400 1 10000
400 x 1 workers => 7.23 s, 1382.9 URLs/s
$ python3 foo.py 400 1 10000
400 x 1 workers => 11.3 s, 882.9 URLs/s
Загрузка ЦП составляет от 1% до 3% для каждого python3
рабочего процесса.
Краткое описание
Выбрав медианный результат для каждого случая, мы получим следующее резюме:
Case 1: 20 x 20 workers => 5.22 s, 1914.2 URLs/s ( 10% to 15% CPU/process)
Case 2: 4 x 100 workers => 9.20 s, 1086.4 URLs/s ( 40% to 60% CPU/process)
Case 3: 1 x 400 workers => 13.5 s, 742.8 URLs/s (120% to 125% CPU/process)
Case 4: 400 x 1 workers => 7.23 s, 1382.9 URLs/s ( 1% to 3% CPU/process
Вопрос
Почему 20 процессов x 20 потоков работают лучше, чем 4 процесса x 100 потоков, даже если у меня только 4 процессора?