Почему CPU bound выполняется за одно и тоже время в потоках и процессах?

Задание 1, 2, 3 - нужно сделать 10 запросов на сайт синхронно, потоками и процессами. На скрине видно что потоки справляются лучше всех, как и предполагалось.

Задание 4, 5, 6 - нужно 10 раз запустить цикл (range(5_000_000)). По идее процессы должны дать лучший результат, потому что проходят цикл одновременно. Но на скрине видно что у всех способов примерно одинаковое время выполнения.

Вывод на моей машине (Процессор 2 ядра 4 потока):

Задание 4 Time: 3.2805144786834717

Задание 5 Time: 3.3142642974853516

Задание 6 Time: 3.6553516387939453

Вопрос: что я сделал не так, и почему результат не такой как предполагалось???

import time
import requests

from threading import Thread
from multiprocessing import Process


# Замеряет время выполнения
def measure_time(func):
    def measure():
        begin = time.time()
        n = func()
        print("Задание", n)
        print("Time:", time.time() - begin, end="\n\n")

    return measure


# Для IO bound
def t():
    res = requests.get('https://google.com')


# Для CPU bound
def countdown():
    i = 0
    while i < 5_000_000:
        i += 1


# Задание 1
@measure_time
def one():
    res = list()
    for i in range(10):
        res.append(requests.get('https://google.com'))
    return 1


# Задание 2
@measure_time
def two():
    threads = list()
    for i in range(10):
        threads.append(Thread(target=t))

    for thrd in threads:
        thrd.start()
    for thrd in threads:
        thrd.join()

    return 2


# Задание 3
@measure_time
def three():
    processes = list()
    for i in range(10):
        processes.append(Process(target=t))

    for prc in processes:
        prc.start()
    for prc in processes:
        prc.join()

    return 3


# Задание 4
@measure_time
def four():
    for i in range(10):
        countdown()
    return 4


# Задание 5
@measure_time
def five():
    threads = list()
    for i in range(10):
        threads.append(Thread(target=countdown))

    for thrd in threads:
        thrd.start()
    for thrd in threads:
        thrd.join()

    return 5


# Задание 6
@measure_time
def six():
    processes = list()
    for i in range(10):
        processes.append(Process(target=countdown))

    for prc in processes:
        prc.start()
    for prc in processes:
        prc.join()

    return 6


if __name__ == "__main__":
    # IO bound
    one()
    two()
    three()

    # CPU bound
    four()
    five()
    six()

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

Автор решения: Сергей

Вы все сделали почти правильно, на мой взгляд.Вот результаты на моей машине. Выигрыш при потоках более 40%.

Задание 4 Time: 2.719468355178833

Задание 5 Time: 2.493485450744629

Задание 6 Time: 1.762448787689209

Но можно улучшить - зачем вам join()? Выигрыш более двух раз. (Обновил: эта идея с устранением join() неверна. См.комментарии)

Задание 4 Time: 2.6073203086853027

Задание 5 Time: 2.0994372367858887

Задание 6 Time: 0.7468335628509521

И обратите внимание, чтобы другие программы не мешали в этот момент. Запустил сейчас, когда вентилятор зашумел (какая-то программа "проснулась") - разницы между 5 и 6 почти не стало.

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

В общем, я провёл некоторые эксперименты, почитал всякое... Короче, всё упирается в GIL как обычно. Почему мультипроцессинг тоже в него упирается, хотя вроде бы не должен - я пока не разобрался. Но в общем если отпускать GIL, например, с помощью time.sleep, то получаются уже другие результаты - параллельная версия работает до 10 раз быстрее, чем последовательная... Но выгоды всё-равно никакой нет, потому что time.sleep съедает всю выгоду. В итоге параллельные версии работают столько же, сколько раньше работали все все версии, а последовательная до 10 раз дольше, чем раньше.
В общем, в случае чисто питоновского кода из-за GIL нет почти никакого смысла в мультипоточности и мультипроцессности, увы.
Содержимое цикла для экспериментов:

# Для CPU bound
def countdown(j):
    #print(j, 'start', flush=True)
    i = 0
    n = 5_000_000
    m = 10_000
    k = n // m
    for _ in range(m):
        for __ in range(k):
            i += 1
        time.sleep(0.00001)
    #print(j, 'end', flush=True)
    return s

Можно побаловаться цифрами, делая больше внутренний цикл либо внешний, но ничего путного в итоге у меня не получилось на таких вычислениях. Может просто потому, что в Google Colab, кажется, всего два ядра CPU. Может быть если ядер будет больше, то какой-то выигрыш всё же будет.

→ Ссылка