400 потоков в 20 процессах превосходят 400 потоков в 4 процессах при выполнении задачи, связанной с вводом / выводом - PullRequest
4 голосов
/ 23 мая 2019

Экспериментальный код

Вот экспериментальный код, который может запускать указанное количество рабочих процессов, а затем запускать указанное количество рабочих потоков в каждом процессе и выполнять задачу выборки 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 процессора?

1 Ответ

2 голосов
/ 23 мая 2019

Ваша задача связана с вводом-выводом, а не с процессором: потоки проводят большую часть времени в состоянии сна, ожидая сетевых данных и т. Д., А не используя процессор.

Таким образом, добавление большего количества потоков, чем процессоров, работает здесь, пока ввод-вывод остается узким местом. Эффект исчезнет только тогда, когда будет столько потоков, что их будет достаточно для того, чтобы начать активно конкурировать за циклы ЦП (или когда пропускная способность вашей сети исчерпана, в зависимости от того, что наступит раньше).


Что касается того, почему 20 потоков на процесс быстрее, чем 100 потоков на процесс: это, скорее всего, связано с GIL CPython. Потоки Python в одном и том же процессе должны ждать не только ввода-вывода, но и друг друга.
При работе с вводом / выводом машин Python:

  1. Преобразует все задействованные объекты Python в объекты C (во многих случаях это можно сделать без физического копирования данных)
  2. Выпускает GIL
  3. Выполнение ввода-вывода в C (что предполагает ожидание его в течение произвольного времени)
  4. Возвращает GIL
  5. Преобразует результат в объект Python, если применимо

Если в одном и том же процессе достаточно потоков, становится все более вероятным, что при достижении шага 4 активен другой поток, вызывая дополнительную случайную задержку.


Теперь, когда речь идет о множестве процессов, в игру вступают другие факторы, такие как перестановка памяти (поскольку в отличие от потоков процессы, выполняющие один и тот же код, не разделяют память) (я почти уверен, что существуют другие задержки из-за большого количества процессы, в отличие от потоков, конкурирующих за ресурсы, но не могу указать это из головы). Вот почему производительность становится нестабильной.

...