Які відмінності між різьбовим та багатопроцесорним модулями?


141

Я вчуся використовувати параметри threadingта multiprocessingмодулі в Python для виконання певних операцій паралельно та прискорення коду.

Мені це важко (можливо, тому, що я не маю жодної теоретичної основи щодо цього), щоб зрозуміти, в чому різниця між threading.Thread()об'єктом та предметом multiprocessing.Process().

Крім того, мені не зовсім зрозуміло, як встановити чергу чергових завдань і мати лише 4 (наприклад) їх роботи паралельно, а інші чекають звільнення ресурсів перед виконанням.

Я вважаю приклади в документації чіткими, але не дуже вичерпними; як тільки я спробую трохи ускладнити речі, я отримую багато дивних помилок (наприклад, метод, який не можна маринувати тощо).

Отже, коли я повинен використовувати модулі threadingта multiprocessing?

Чи можете ви зв’язати мене з деякими ресурсами, які пояснюють поняття, що стоять за цими двома модулями, і як правильно їх використовувати для складних завдань?


Є більше, є також Threadмодуль (викликається _threadв python 3.x). Якщо чесно, я ніколи не розумів відмінностей сам ...
Данно

3
@Dunno: Як Thread/ _threadдокументація прямо говорить, це "примітиви низького рівня". Ви можете використовувати його для створення власних об'єктів синхронізації, для керування порядком з'єднання дерева ниток і т. Д. Якщо ви не можете уявити, для чого вам потрібно використовувати його, не використовуйте його і не дотримуйтесь threading.
abarnert

Відповіді:


260

Те, що говорить Джуліо Франко , стосується багатопотокової та багатопроцесорної обробки в цілому .

Однак у Python * є додаткова проблема: Існує глобальний замок інтерпретатора, який не дозволяє двом потокам в одному і тому ж процесі запускати код Python одночасно. Це означає, що якщо у вас є 8 ядер, і ви зміните свій код на 8 потоків, він не зможе використовувати 800% CPU і запустити 8x швидше; він буде використовувати той самий 100% процесор і працювати з однаковою швидкістю. (Насправді вона працюватиме трохи повільніше, оскільки є додаткові накладні витрати від нанизування, навіть якщо у вас немає спільних даних, але поки що це ігноруйте.)

З цього є винятки. Якщо важкі обчислення вашого коду насправді не відбуваються в Python, але в деякій бібліотеці зі спеціальним кодом C, який робить належну обробку GIL, як нумерований додаток, ви отримаєте очікувану вигоду від продуктивності від нарізки. Те ж саме, якщо важкі обчислення виконуються деяким підпроцесом, який ви запускаєте і чекаєте.

Що ще важливіше, є випадки, коли це не має значення. Наприклад, мережевий сервер витрачає більшу частину свого часу на читання пакетів з мережі, а додаток GUI витрачає більшу частину свого часу на очікування подій користувача. Однією з причин використання потоків у мережевому сервері чи додатку GUI є те, що ви можете виконувати тривалі «фонові завдання», не зупиняючи головний потік продовжувати обслуговувати мережеві пакети чи події GUI. І це добре працює з потоками Python. (У технічному плані це означає, що потоки Python надають вам паралельність, хоча вони не дають вам паралелізму ядра.)

Але якщо ви пишете програму, пов'язану з процесором, в чистому Python, використання більшої кількості потоків, як правило, не корисно.

Використання окремих процесів не має таких проблем з GIL, оскільки кожен процес має свій окремий GIL. Звичайно, ви все ще маєте ті самі компроміси між потоками та процесами, як і будь-якими іншими мовами - обмінюватися даними між процесами складніше і дорожче, ніж між потоками, запускати величезну кількість процесів або створювати та знищувати це може бути дорого. їх часто і т. д. Але GIL важить велику рівновагу до процесів, таким чином, що не відповідає дійсності, скажімо, C або Java. Отже, у Python ви опинитесь багатопроцесорними, але набагато частіше, ніж у C чи Java.


Тим часом філософія Python "включені батареї" приносить добру новину: писати код дуже легко, який можна перемикати назад і вперед між потоками та процесами, змінюючи однолінійку.

Якщо ви розробляєте свій код з точки зору автономних "завдань", які не поділяють нічого з іншими завданнями (або основною програмою), окрім введення та виводу, ви можете використовувати concurrent.futuresбібліотеку для запису коду навколо пулу потоків таким чином:

with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    executor.submit(job, argument)
    executor.map(some_function, collection_of_independent_things)
    # ...

Ви навіть можете отримати результати цих завдань і передати їх на подальші робочі місця, чекати речей у порядку виконання або в порядку завершення тощо; Futureдетально прочитайте розділ на об'єктах.

Тепер, якщо виявиться, що у вашій програмі постійно використовується 100% процесор, а додавання більшої кількості потоків просто робить це повільніше, ви стикаєтеся з проблемою GIL, тому вам потрібно перейти до процесів. Все, що вам потрібно зробити - це змінити перший рядок:

with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:

Єдине справжнє застереження полягає в тому, що аргументи ваших завдань і повернені значення повинні бути підбірними (і не потребувати занадто багато часу або пам’яті, щоб підбирати), щоб бути корисним перехресним процесом. Зазвичай це не проблема, але іноді це є.


Але що робити, якщо ваші роботи не можуть бути самостійними? Якщо ви можете розробити свій код з точки зору завдань, які передають повідомлення одне до іншого, це все ще досить просто. Можливо, вам доведеться використовувати threading.Threadабо multiprocessing.Processзамість цього покладатися на басейни. І вам доведеться явно створювати queue.Queueабо multiprocessing.Queueоб’єкти. (Є багато інших варіантів - труби, розетки, файли зі зграями… Але справа в тому, що вам доведеться щось робити вручну, якщо автоматична магія Виконавця недостатня.)

Але що робити, якщо ви навіть не можете покластися на передачу повідомлення? Що робити, якщо вам потрібно два завдання, щоб обидві мутувати одну і ту ж структуру і бачити зміни один одного? У цьому випадку вам потрібно буде виконати ручну синхронізацію (блокування, семафори, умови тощо) і, якщо ви хочете використовувати процеси, явні об'єкти спільної пам'яті для завантаження. Це коли багатопотокове чи багатопроцесорне ускладнення. Якщо ви можете уникнути цього, чудово; якщо ви не можете, вам потрібно буде прочитати більше, ніж хтось зможе відповісти на відповідь.


З коментаря ви хотіли дізнатися, що відрізняється між потоками та процесами в Python. Дійсно, якщо ви прочитаєте відповідь Джуліо Франко і мої та всі наші посилання, це повинно охоплювати все… але резюме, безумовно, буде корисним, так ось:

  1. Нитки діляться даними за замовчуванням; процесів немає.
  2. Як наслідок (1), передача даних між процесами, як правило, вимагає підбирання та видалення їх. **
  3. Як інший наслідок (1), безпосередньо обмін даними між процесами, як правило, вимагає розміщення їх у форматах низького рівня, таких як Value, Array та ctypesтипи.
  4. Процеси не підлягають GIL.
  5. На деяких платформах (в основному Windows) процеси набагато дорожчі для створення та знищення.
  6. Існують деякі додаткові обмеження щодо процесів, деякі з яких різні на різних платформах. Докладніше див. Вказівки щодо програмування .
  7. threadingМодуль не має деякі особливості multiprocessingмодуля. (Ви можете використовувати multiprocessing.dummyдля отримання більшості відсутніх API поверх потоків, або ви можете використовувати модулі вищого рівня, як, concurrent.futuresі не турбуватися про це.)

* Ця проблема не є насправді Python, мовою, але CPython, "стандартною" реалізацією цієї мови. Деякі інші реалізації не мають GIL, як Jython.

** Якщо ви використовуєте метод запуску fork для багатопроцесорної обробки, який ви можете використовувати на більшості платформ, що не належать до Windows, кожен дочірній процес отримує будь-які ресурси, у яких був батько, коли дитина була запущена, що може бути ще одним способом передачі даних дітям.


дякую, але я не впевнений, що все зрозумів. У будь-якому разі я намагаюся зробити це трохи для навчальних цілей, і трохи тому, що з наївним використанням теми я вдвічі скоротив свій код (починаючи більше 1000 ниток одночасно, кожен викликаючи зовнішню програму. Це насичує процесор, все ж спостерігається збільшення швидкості x2). Я думаю, що
розумне

3
@LucaCerone: Ах, якщо ваш код витрачає більшу частину часу на очікування на зовнішні програми, то так, це виграє від нанизування. Гарна думка. Дозвольте мені відредагувати відповідь, щоб пояснити це.
abarnert

2
@LucaCerone: Тим часом, яких частин ти не розумієш? Не знаючи рівня знань, з якого ви починаєте, важко написати хорошу відповідь… але, маючи певний відгук, можливо, ми можемо придумати щось, що корисно вам і майбутнім читачам.
abarnert

3
@LucaCerone Ви повинні прочитати PEP для мультиобробки тут . Він дає таймінги та приклади потоків проти багатопроцесорної обробки.
mr2ert

1
@LucaCerone: Якщо об'єкт, до якого прив’язаний метод, не має жодного складного стану, найпростішим вирішенням проблеми маринування є написання дурної функції обгортки, яка генерує об'єкт і викликає його метод. Якщо це дійсно має складний стан, то вам , ймовірно , потрібно зробити його придатний для консервування (який досить легко, як pickleдокументи пояснити це), а потім в гіршому випадку вашої дурна обгортка def wrapper(obj, *args): return obj.wrapper(*args).
abarnert

32

В одному процесі може існувати декілька потоків. Нитки, що належать до одного процесу, мають однакову область пам’яті (можуть читати і записувати до самих змінних і можуть заважати одна одній). Навпаки, різні процеси живуть в різних областях пам'яті, і кожен з них має свої змінні. Для того, щоб спілкуватися, процеси повинні використовувати інші канали (файли, труби або розетки).

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

Якщо говорити про продуктивність, потоки створюються та керуються швидше, ніж процеси (оскільки ОС не потрібно виділяти цілу нову область віртуальної пам'яті), а міжпотокове спілкування, як правило, швидше, ніж міжпроцесорне спілкування. Але нитки важче запрограмувати. Нитки можуть заважати одна одній і можуть записувати в пам'ять один одному, але спосіб цього відбувається не завжди очевидно (через декілька факторів, в основному переупорядкування інструкцій та кешування пам’яті), і тому вам знадобляться примітиви синхронізації для управління доступом вашим змінним.


12
Тут не вистачає дуже важливої ​​інформації про GIL, яка робить його оманливим.
abarnert

1
@ mr2ert: Так, це дуже важлива інформація в двох словах. :) Але це трохи складніше, ніж це, тому я написав окрему відповідь.
abarnert

2
Я подумав, що прокоментував, сказавши, що @abarnert має рацію, і забув про GIL, відповідаючи тут. Тож ця відповідь є неправильною, не варто її підтверджувати.
Джуліо Франко

6
Я відповів на цю відповідь, оскільки вона все ще зовсім не відповідає, в чому різниця між Python threadingі multiprocessing.
Антті Хаапала

Я читав, що GIL для кожного процесу. Але чи всі процеси використовують один і той же інтерпретатор python чи є окремий інтерпретатор на кожну нитку?
змінна

3

Я вважаю, що це посилання відповідає на ваше питання елегантно.

Коротше кажучи, якщо одній із ваших неполадок доводиться чекати, поки інша закінчиться, багатопотокове читання добре (наприклад, у важких операціях вводу / виводу, наприклад); навпаки, якщо ваші субпроблеми можуть дійсно статися одночасно, пропонується багатообробка. Однак ви не створите більше процесів, ніж ваша кількість ядер.


3

Цитати документації Python

Я виділив основні цитати документації Python про Process vs Threads та GIL за адресою: Що таке блокування глобального інтерпретатора (GIL) у CPython?

Експерименти з процесами та потоками

Я зробив трохи бенчмаркінгу, щоб конкретніше показати різницю.

У еталоні я призначав роботу CPU та IO, пов'язану з різною кількістю потоків на 8 процесорі з гіперпотоком . Робота, що надається на одну нитку, завжди однакова, так що більше ниток означає більше загальної роботи.

Результати:

введіть тут опис зображення

Дані графіку .

Висновки:

  • для роботи, пов'язаної з процесором, багатопроцесорна робота завжди швидша, імовірно, завдяки GIL

  • для роботи, пов'язаної з IO. обидва мають однакову швидкість

  • нитки мають масштаб лише до приблизно 4 разів замість очікуваного 8x, оскільки я перебуваю на 8-ти машині з гіперпотоком.

    На противагу цьому, з роботою, пов'язаною з процесором C POSIX, яка досягає очікуваного 8-кратного прискорення: Що означають "реальні", "користувачі" та "sys" у виході часу (1)?

    TODO: Я не знаю причини цього, повинні бути інші неефективність Python.

Код тесту:

#!/usr/bin/env python3

import multiprocessing
import threading
import time
import sys

def cpu_func(result, niters):
    '''
    A useless CPU bound function.
    '''
    for i in range(niters):
        result = (result * result * i + 2 * result * i * i + 3) % 10000000
    return result

class CpuThread(threading.Thread):
    def __init__(self, niters):
        super().__init__()
        self.niters = niters
        self.result = 1
    def run(self):
        self.result = cpu_func(self.result, self.niters)

class CpuProcess(multiprocessing.Process):
    def __init__(self, niters):
        super().__init__()
        self.niters = niters
        self.result = 1
    def run(self):
        self.result = cpu_func(self.result, self.niters)

class IoThread(threading.Thread):
    def __init__(self, sleep):
        super().__init__()
        self.sleep = sleep
        self.result = self.sleep
    def run(self):
        time.sleep(self.sleep)

class IoProcess(multiprocessing.Process):
    def __init__(self, sleep):
        super().__init__()
        self.sleep = sleep
        self.result = self.sleep
    def run(self):
        time.sleep(self.sleep)

if __name__ == '__main__':
    cpu_n_iters = int(sys.argv[1])
    sleep = 1
    cpu_count = multiprocessing.cpu_count()
    input_params = [
        (CpuThread, cpu_n_iters),
        (CpuProcess, cpu_n_iters),
        (IoThread, sleep),
        (IoProcess, sleep),
    ]
    header = ['nthreads']
    for thread_class, _ in input_params:
        header.append(thread_class.__name__)
    print(' '.join(header))
    for nthreads in range(1, 2 * cpu_count):
        results = [nthreads]
        for thread_class, work_size in input_params:
            start_time = time.time()
            threads = []
            for i in range(nthreads):
                thread = thread_class(work_size)
                threads.append(thread)
                thread.start()
            for i, thread in enumerate(threads):
                thread.join()
            results.append(time.time() - start_time)
        print(' '.join('{:.6e}'.format(result) for result in results))

GitHub висхідний + графічний код у тій самій каталозі .

Тестовано на Ubuntu 18.10, Python 3.6.7, на ноутбуці Lenovo ThinkPad P51 з процесором: процесор Intel Core i7-7820HQ (4 ядра / 8 потоків), оперативна пам'ять: 2x Samsung M471A2K43BB1-CRC (2x 16GiB), SSD: Samsung MZVLB512HAJQ- 000L7 (3000 МБ / с).

Візуалізуйте, які потоки запущені в даний момент часу

Ця публікація https://rohanvarma.me/GIL/ навчила мене, що ви можете запускати зворотний виклик кожного разу, коли запланована нитка з target=аргументомthreading.Thread та тим самим для multiprocessing.Process.

Це дозволяє нам переглядати, який саме потік працює кожного разу. Коли це буде зроблено, ми побачили б щось подібне (я склав цей конкретний графік):

            +--------------------------------------+
            + Active threads / processes           +
+-----------+--------------------------------------+
|Thread   1 |********     ************             |
|         2 |        *****            *************|
+-----------+--------------------------------------+
|Process  1 |***  ************** ******  ****      |
|         2 |** **** ****** ** ********* **********|
+-----------+--------------------------------------+
            + Time -->                             +
            +--------------------------------------+

що б показало, що:

  • нитки повністю серіалізовані GIL
  • процеси можуть працювати паралельно

1

Ось деякі дані про ефективність для python 2.6.x, які закликають поставити під сумнів поняття, що потоки є більш ефективними, ніж багатопроцесорна робота в сценаріях, пов'язаних з IO. Ці результати отримані від 40-процесорної системи IBM System x3650 M4 BD.

Обробка IO-обробка: Процес-пул працює краще, ніж Пул потоків

>>> do_work(50, 300, 'thread','fileio')
do_work function took 455.752 ms

>>> do_work(50, 300, 'process','fileio')
do_work function took 319.279 ms

Обробка, пов'язана з процесором: Процес-пул працює краще, ніж Пул потоків

>>> do_work(50, 2000, 'thread','square')
do_work function took 338.309 ms

>>> do_work(50, 2000, 'process','square')
do_work function took 287.488 ms

Це не суворі тести, але вони мені кажуть, що багатопроцесорна робота не зовсім неефективна порівняно з нарізкою.

Код, який використовується в інтерактивній консолі пітона для вищезазначених тестів

from multiprocessing import Pool
from multiprocessing.pool import ThreadPool
import time
import sys
import os
from glob import glob

text_for_test = str(range(1,100000))

def fileio(i):
 try :
  os.remove(glob('./test/test-*'))
 except : 
  pass
 f=open('./test/test-'+str(i),'a')
 f.write(text_for_test)
 f.close()
 f=open('./test/test-'+str(i),'r')
 text = f.read()
 f.close()


def square(i):
 return i*i

def timing(f):
 def wrap(*args):
  time1 = time.time()
  ret = f(*args)
  time2 = time.time()
  print '%s function took %0.3f ms' % (f.func_name, (time2-time1)*1000.0)
  return ret
 return wrap

result = None

@timing
def do_work(process_count, items, process_type, method) :
 pool = None
 if process_type == 'process' :
  pool = Pool(processes=process_count)
 else :
  pool = ThreadPool(processes=process_count)
 if method == 'square' : 
  multiple_results = [pool.apply_async(square,(a,)) for a in range(1,items)]
  result = [res.get()  for res in multiple_results]
 else :
  multiple_results = [pool.apply_async(fileio,(a,)) for a in range(1,items)]
  result = [res.get()  for res in multiple_results]


do_work(50, 300, 'thread','fileio')
do_work(50, 300, 'process','fileio')

do_work(50, 2000, 'thread','square')
do_work(50, 2000, 'process','square')

Я використав ваш код (видалив глобальну частину) і знайшов ці цікаві результати з Python 2.6.6:>>> do_work(50, 300, 'thread', 'fileio') --> 237.557 ms >>> do_work(50, 300, 'process', 'fileio') --> 323.963 ms >>> do_work(50, 2000, 'thread', 'square') --> 232.082 ms >>> do_work(50, 2000, 'process', 'square') --> 282.785 ms
Алан Гаррідо

-5

Ну, на більшість запитань відповідає Джуліо Франко. Я детальніше зупинюсь на проблемі споживача-виробника, яка, напевно, поставить вас на правильний шлях для вирішення проблеми використання багатопотокового додатку.

fill_count = Semaphore(0) # items produced
empty_count = Semaphore(BUFFER_SIZE) # remaining space
buffer = Buffer()

def producer(fill_count, empty_count, buffer):
    while True:
        item = produceItem()
        empty_count.down();
        buffer.push(item)
        fill_count.up()

def consumer(fill_count, empty_count, buffer):
    while True:
        fill_count.down()
        item = buffer.pop()
        empty_count.up()
        consume_item(item)

Ви можете прочитати більше про примітиви синхронізації з:

 http://linux.die.net/man/7/sem_overview
 http://docs.python.org/2/library/threading.html

Псевдокод вище. Я думаю, вам слід шукати проблему виробника-споживача, щоб отримати більше посилань.


шкода innosam, але це здається мені C ++? дякую за посилання :)
lucacerone

Власне, ідеї, що стоять за багатопроцесорною та багатопотоковою інформацією, не залежать від мови. Рішення було б подібним до наведеного вище коду.
innosam

2
Це не C ++; це псевдокод (або це код для мови, що в основному динамічно набирається, з синтаксисом, подібним до С. Враховуючи це, я думаю, що корисніше писати псевдокод, подібний до Python, для навчання користувачів Python. виявляється кодом для запуску, або принаймні близьким до нього, що рідко стосується псевдокоду, схожого на С…)
abarnert

Я переписав його як псевдокод, подібний до Python (також використовуючи OO та передаючи параметри замість глобальних об'єктів); не соромтеся повернутися, якщо ви думаєте, що це робить менш зрозумілими речі.
abarnert

Крім того, варто відзначити, що в stdlib Python вбудована синхронізована черга, яка містить усі ці деталі, а API-файли для потоку та пулу процесів ще більше абстрактні речі. Безумовно, варто зрозуміти, як синхронізовані черги працюють під обкладинками, але вам рідко буде потрібно писати одну самостійно.
abarnert
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.