Затраты на создание потока против процесса в Linux - PullRequest
0 голосов
/ 05 сентября 2018

Я пытаюсь ответить на вопрос, сколько накладных расходов при создании потока против процесса в Python. Я изменил код из аналогичного вопроса, который в основном выполняет функцию с двумя потоками, а затем выполняет ту же функцию с двумя процессами и сообщает время.

import time, sys
NUM_RANGE = 100000000

from multiprocessing  import Process
import threading

def timefunc(f):
    t = time.time()
    f()
    return time.time() - t

def multiprocess():
    class MultiProcess(Process):
        def __init__(self):
            Process.__init__(self)

        def run(self):
            # Alter string + test processing speed
            for i in xrange(NUM_RANGE):
                a = 20 * 20


    for _ in xrange(300):
      MultiProcess().start()

def multithreading():
    class MultiThread(threading.Thread):
        def __init__(self):
            threading.Thread.__init__(self)

        def run(self):
            # Alter string + test processing speed
            for i in xrange(NUM_RANGE):
                a = 20 * 20

    for _ in xrange(300):
      MultiThread().start()

print "process run time" + str(timefunc(multiprocess))
print "thread run time" + str(timefunc(multithreading))

Тогда я получил 7,9 с для многопроцессорной обработки и 7,9 с для многопоточности

Основной вопрос, на который я пытаюсь ответить, заключается в том, целесообразно ли использовать многопоточность или многопроцессорность для тысяч сетевых запросов именно в Linux. Похоже, в соответствии с этим кодом они одинаковы с точки зрения времени запуска, но, возможно, процессы гораздо тяжелее в использовании памяти?

Ответы [ 3 ]

0 голосов
/ 05 сентября 2018

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

Во-первых, многопоточность в python не похожа на многопоточность в Linux. В то время как Linux создает новый облегченный процесс для каждого потока, и они могут работать на разных ядрах ЦП, сценарий python и его потоки запускаются в одно и то же ядро ​​ЦП в любой момент времени. Если вы хотите истинную многопроцессорность в python, вам нужно использовать многопроцессорный интерфейс.

Чтобы продемонстрировать вышеизложенное, запустите системный монитор Linux, выберите вкладку ресурсов, а затем в другом окне терминала попробуйте запустить каждый из двух фрагментов кода, которые я вставил ниже. На вкладке ресурсов показана нагрузка на каждое ядро ​​ЦП.

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

Один из способов справиться с этим, если вы решите не иметь одновременно большого количества активных процессов, - это создать фиксированное число процессов, сохранить их в списке и затем передать входящие соединения им как они приходят. Когда все процессы заняты, вы ждете. Для этого вам понадобится хотя бы один счетный семафор.

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

Хорошо, вот примеры кода для многопоточности и многопоточности. Первый запускает 5 потоков. Второй запускает 5 процессов. Вы можете изменить их одним редактированием, чтобы увеличить их до 100, 1000 и т. Д. Целочисленная обработка зацикливается в каждом, что позволяет увидеть загрузку в программе системного монитора Linux.

#!/usr/bin/python

# Parallel code with shared variables, using threads
from threading import Lock, Thread
from time import sleep

# Variables to be shared across threads
counter = 0
run = True
lock = Lock()

# Function to be executed in parallel
def myfunc():

    # Declare shared variables
    global run
    global counter
    global lock

    # Processing to be done until told to exit
    while run:
        n = 0
        for i in range(10000):
            n = n+i*i
        print( n )
        sleep( 1 )



        # Increment the counter
        lock.acquire()
        counter = counter + 1
        lock.release()

    # Set the counter to show that we exited
    lock.acquire()
    counter = -1
    lock.release()
    print( 'thread exit' )

# ----------------------------

# Launch the parallel function in a set of threads
tlist = []
for n in range(5):
    thread = Thread(target=myfunc)
    thread.start()
    tlist.append(thread)

# Read and print the counter
while counter < 5:
    print( counter )
    n = 0
    for i in range(10000):
        n = n+i*i
    print( n )
    #sleep( 1 )

# Change the counter    
lock.acquire()
counter = 0
lock.release()

# Read and print the counter
while counter < 5:
    print( counter )
    n = 0
    for i in range(10000):
        n = n+i*i
    print( n )
    #sleep( 1 )

# Tell the thread to exit and wait for it to exit
run = False

for thread in tlist:
    thread.join()

# Confirm that the thread set the counter on exit
print( counter )

А вот и многопроцессорная версия:

#!/usr/bin/python

from time import sleep
from multiprocessing import Process, Value, Lock

def myfunc(counter, lock, run):

    while run.value:
        sleep(1)
        n=0
        for i in range(10000):
            n = n+i*i
        print( n )
        with lock:
            counter.value += 1
            print( "thread %d"%counter.value )

    with lock:
        counter.value = -1
        print( "thread exit %d"%counter.value )

# -----------------------

counter = Value('i', 0)
run = Value('b', True)
lock = Lock()

plist = []
for n in range(5):
    p = Process(target=myfunc, args=(counter, lock, run))
    p.start()
    plist.append(p)


while counter.value < 5:
    print( "main %d"%counter.value )
    n=0
    for i in range(10000):
        n = n+i*i
    print( n )
    sleep(1)

with lock:
    counter.value = 0

while counter.value < 5:
    print( "main %d"%counter.value )
    sleep(1)

run.value = False

for p in plist:
    p.join()

print( "main exit %d"%counter.value)
0 голосов
/ 08 сентября 2018

Ваш код не подходит для сравнения времени запуска между процессами и потоками. Многопоточность Python-код (в CPython) означает одноядерный. Любое выполнение кода Python в одном потоке исключает обработку для всех других потоков в этом процессе, пока этот поток удерживает глобальную блокировку интерпретатора ( GIL ). Это означает, что вы можете иметь параллелизм только с потоками, а не истинный параллелизм, если это касается байт-кода Python.

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


TL; DR

Запуск потока (тестируется в Ubuntu 18.04) во много раз дешевле, чем запуск процесса.

По сравнению с запуском потока запуск процесса с указанными параметрами start_methods занимает:

  • вилка : ~ в 33 раза длиннее
  • forkserver : ~ 6693x больше
  • spawn : ~ 7558x больше

Полные результаты внизу.


Benchmark

Я недавно обновился до Ubuntu 18.04 и протестировал запуск со скриптом, который, надеюсь, ближе к истине. Обратите внимание, что этот код - Python 3.

Некоторые утилиты для форматирования и сравнения результатов теста:

# thread_vs_proc_start_up.py
import sys
import time
import pandas as pd
from threading import Thread
import multiprocessing as mp
from multiprocessing import Process, Pipe


def format_secs(sec, decimals=2) -> str:
    """Format subseconds.

    Example:
    >>>format_secs(0.000_000_001)
    # Out: '1.0 ns'
    """
    if sec < 1e-6:
        return f"{sec * 1e9:.{decimals}f} ns"
    elif sec < 1e-3:
        return f"{sec * 1e6:.{decimals}f} µs"
    elif sec < 1:
        return f"{sec * 1e3:.{decimals}f} ms"
    elif sec >= 1:
        return f"{sec:.{decimals}f} s"

def compare(value, base):
    """Return x-times relation of value and base."""
    return f"{(value / base):.2f}x"


def display_results(executor, result_series):
    """Display results for Executor."""
    exe_str = str(executor).split(".")[-1].strip('\'>')
    print(f"\nresults for {exe_str}:\n")

    print(result_series.describe().to_string(), "\n")
    print(f"Minimum with {format_secs(result_series.min())}")
    print("-" * 60)

Эталонный тест функционирует ниже. Для каждого теста из n_runs создается новая труба. Запускается новый процесс или поток (исполнитель), и целевая функция calc_start_up_time немедленно возвращает разницу во времени. Вот и все.

def calc_start_up_time(pipe_in, start):
    pipe_in.send(time.perf_counter() - start)
    pipe_in.close()


def run(executor, n_runs):

    results = []
    for _ in range(int(n_runs)):
        pipe_out, pipe_in = Pipe(duplex=False)
        exe = executor(target=calc_start_up_time, args=(pipe_in,
                                                    time.perf_counter(),))
        exe.start()
        # Note: Measuring only the time for exe.start() returning like:
        # start = time.perf_counter()
        # exe.start()
        # end = time.perf_counter()
        # would not include the full time a new process needs to become
        # production ready.
        results.append(pipe_out.recv())
        pipe_out.close()
        exe.join()

    result_series = pd.Series(results)
    display_results(executor, result_series)
    return result_series.min()

Сборка запускается из терминала с параметром start_method, а количество прогонов передается в качестве аргументов командной строки. Тест всегда будет запускать n_runs запуска процесса с указанным параметром start_method (доступно в Ubuntu 18.04: fork, spawn, forkserver), а затем сравнивать с n_runs запуска потока. Результаты сосредоточены на минимумах, потому что они показывают, насколько быстро это возможно.

if __name__ == '__main__':

    # Usage:
    # ------
    # Start from terminal with start_method and number of runs as arguments:
    #   $python thread_vs_proc_start_up.py fork 100
    #
    # Get all available start methods on your system with:
    # >>>import multiprocessing as mp
    # >>>mp.get_all_start_methods()

    start_method, n_runs = sys.argv[1:]
    mp.set_start_method(start_method)

    mins = []
    for executor in [Process, Thread]:
        mins.append(run(executor, n_runs))
    print(f"Minimum start-up time for processes takes "
          f"{compare(*mins)} "
          f"longer than for threads.")


Результаты

с n_runs=1000 на моей ржавой машине:

# Ubuntu 18.04 start_method: fork
# ================================
results for Process:

count    1000.000000
mean        0.002081
std         0.000288
min         0.001466
25%         0.001866
50%         0.001973
75%         0.002268
max         0.003365 

Minimum with 1.47 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000054
std         0.000013
min         0.000044
25%         0.000047
50%         0.000051
75%         0.000058
max         0.000319 

Minimum with 43.89 µs
------------------------------------------------------------
Minimum start-up time for processes takes 33.41x longer than for threads.

# Ubuntu 18.04 start_method: spawn
# ================================

results for Process:

count    1000.000000
mean        0.333502
std         0.008068
min         0.321796
25%         0.328776
50%         0.331763
75%         0.336045
max         0.415568 

Minimum with 321.80 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000056
std         0.000016
min         0.000043
25%         0.000046
50%         0.000048
75%         0.000065
max         0.000231 

Minimum with 42.58 µs
------------------------------------------------------------
Minimum start-up time for processes takes 7557.80x longer than for threads.

# Ubuntu 18.04 start_method: forkserver
# =====================================


results for Process:

count    1000.000000
mean        0.295011
std         0.007157
min         0.287871
25%         0.291440
50%         0.293263
75%         0.296185
max         0.361581 

Minimum with 287.87 ms
------------------------------------------------------------

results for Thread:

count    1000.000000
mean        0.000055
std         0.000014
min         0.000043
25%         0.000045
50%         0.000047
75%         0.000064
max         0.000251 

Minimum with 43.01 µs
------------------------------------------------------------
Minimum start-up time for processes takes 6693.44x longer than for threads.
0 голосов
/ 05 сентября 2018

Это зависит ... и, возможно, "оба" могут быть ответом, который вы ищете.

Мультипроцесс в python использует стандартный вызов fork () в linux для копирования основного процесса. В случае вашей минимальной программы это, вероятно, не очень много данных, но в зависимости от того, как структурирована конечная программа, может быть даже больше данных, так сказать. В минимальном случае издержки памяти процесса довольно минимальны.

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

Другими словами; даже несмотря на то, что у вас в тесте было то же самое время, под покровом происходило много всего, что не выдержит такой простой тест.

Правильный ответ для работающей программы, вероятно, не беспокоит время запуска, но, вероятно, будет в большей степени зависеть от

  • Чем будет заниматься каждый поток или процесс?
  • К какой памяти и состоянию требуется доступ, будет ли проблема с блокировкой?
  • В Python, GIL будет проблематичным для рабочей нагрузки (2 рабочих потока одновременно будет достаточно для рабочей нагрузки)
  • Умножает отпечаток процесса на количество процессов на допустимый объем памяти

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

Исключением из этого правила является то, как я хочу обрабатывать память и состояние процесса или потоков. Когда вы начнете говорить о большом количестве потоков и подобных процессов, у вас, вероятно, возникнут проблемы с доступом к памяти / блокировкой, о которых стоит подумать ...

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

Некоторым дополнительным материалом, вероятно, будет реструктуризация процесса, требующая меньше потоков. Обычно при создании сетевых серверов и клиентов я использую потоки, и у меня есть один поток слушателя и отправителя, который либо блокирует очередь, либо сокет, ожидая что-то сделать. Вы можете захотеть иметь меньше слушателей и отправителей, просто передавая очереди, таким образом ограничивая накладные расходы. Я думаю, что в Python3.5 + есть новая библиотека asyncio, которая также может упростить вашу жизнь.

Я знаю, что на самом деле не ответил на ваш вопрос, но я надеюсь, что предоставил кое-что для поиска и проверки.

Надеюсь, это поможет!

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...