Python. Как работает GIL в 3.8.5 и есть ли он?

На 4-х ядерном процессоре запускаю код по сложению списка на 50 10е6. Вариант с четырмя потоками из Threading занимает 7 сек, а c четырмя процессами из Pool 8 секунд. Что, в общем-то, одинаково.

import threading
import time
from multiprocessing import Pool


def thread_job(index):
    global result, A
    summ = A[index[0]]
    j = index[0]+1
    while j < index[1]:
        summ += A[j]
        j += 1
    result += summ


def pool_job(x, y):
    global A
    summ = A[x]
    j = x + 1
    while j < y:
        summ += A[j]
        j += 1
    return summ



def counter(b, n):
    intervals = []
    delta = int(b/n)
    x, y = 0, delta
    for i in range(n):
        intervals.append((x, y))
        x, y = x + delta, y + delta
    return intervals


if __name__ == '__main__':
    # Calculate with threads
    b = 50000000
    n = 4
    A = [x for x in range(b)]
    intervals = counter(b, n)
    threads = [threading.Thread(target=thread_job, args=(i, )) for i in intervals]
    time_start = time.time()
    result = 0
    for thread in threads:
        thread.start()
    for thread in threads:
        thread.join()
    print('First variant threads', time.time() - time_start, 'result', result)

    # Calculate with multiprocessing
    result = 0
    time_start = time.time()
    pool = Pool(processes=n)
    r1 = pool.apply(pool_job, intervals[0])
    r2 = pool.apply(pool_job, intervals[1])
    r3 = pool.apply(pool_job, intervals[2])
    r4 = pool.apply(pool_job, intervals[3])

Ответы (2 шт):

Автор решения: Roman Konoval

pool.apply работает синхронно. Для честного сравнения нужно сделать как-то так:

r1 = pool.apply_async(pool_job, intervals[0])
r2 = pool.apply_async(pool_job, intervals[1])
r3 = pool.apply_async(pool_job, intervals[2])
r4 = pool.apply_async(pool_job, intervals[3])
for r in [r1, r2, r3, r4]:
   r.wait()

В варианте с последовательным выполнением, вы получаете результат практически такой же как и с многопоточностью. Что неудивительно и демонстрирует наличие и работу GIL в этом сценарии, который CPU-bound.

Небольшая разница во времени, а именно то, что в многопоточном варианте задачи выполняются 7 секунд против 8 в мнопроцессном варианте, можно объяснить тем, что для запуска задачи в других процессах, нужно передать массивы, которые обрабатываются от главного процесса к процессами обработчикам. В многопоточном варианте, это не нужно, данные разделяются между потоками одного процесса.

Кроме того у вас запуск процессов (Pool(processes=n)), тоже учитывается, а это дополнительное время.

→ Ссылка
Автор решения: CrazyElf

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

  • threadingThread-based parallelism
  • multiprocessingProcess-based parallelism

Создание процессов - более трудоёмкая задача, чем создание потоков. Она требует сериализации данных порождающего процесса, для создания их полной копии для каждого порождённого процесса. Именно поэтому, кстати, вы и вынуждены во втором случае в явном виде возвращать посчитанные данные - изменять в этом случае глобальную переменную без толку, она у каждого процесса будет своя.

Поскольку у процессов своя копия данных, то GIL в случае процессов мешать вообще не должен, но при этом тратится время на копирование данных в порождённый процесс и передачу результата в порождающий процесс.

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

→ Ссылка