python - 在执行 I/O 绑定(bind)任务时,20 个进程中的 400 个线程优于 4 个进程中的 400 个线程

标签 python multithreading performance multiprocessing gil

实验代码

下面是实验代码,可以启动指定数量的工作进程,然后在每个进程内启动指定数量的工作线程,并执行获取 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 数量。

请注意,在所有情况下,我实际上都在点击 www.example.com 域的 URL,即 www.example.com 不仅仅是一个占位符。换句话说,我不加修改地运行上面的代码。

环境

我正在具有 8 GB RAM 和 4 个 CPU 的 Linode 虚拟专用服务器上测试此代码。它正在运行 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 命令监视 CPU 使用率时,我看到每个 python3 工作进程消耗大约 10% 到 15% 的 CPU。

案例 2:4 个进程 x 100 个线程

现在我以为我只有 4 个 CPU。即使我启动 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

每个 python3 工作进程的 CPU 使用率在 40% 到 60% 之间。

案例 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

单个 python3 工作进程的 CPU 使用率在 120% 到 125% 之间。

案例 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

每个 python3 工作进程的 CPU 使用率在 1% 到 3% 之间。

总结

从每个案例中选择中位数结果,我们得到这个总结:

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 个 CPU?

最佳答案

您的任务受 I/O 限制而不是 CPU 限制:线程大部分时间都处于 sleep 状态等待网络数据等,而不是使用 CPU。

因此,只要 I/O 仍然是瓶颈,添加比 CPU 更多的线程在这里是可行的。只有当线程数量如此之多以至于有足够多的线程同时准备好开始积极竞争 CPU 周期时(或者当您的网络带宽耗尽时,以先到者为准),这种影响才会消退。


至于为什么每个进程20个线程比每个进程100个线程快:这很可能是由于CPython的GIL。同一进程中的 Python 线程不仅需要等待 I/O,还需要等待彼此。
在处理 I/O 时,Python 机制:

  1. 将涉及的所有 Python 对象转换为 C 对象(在许多情况下,这可以在不物理复制数据的情况下完成)
  2. 发布 GIL
  3. 在 C 中执行 I/O(包括等待任意时间)
  4. 重新获得 GIL
  5. 如果适用,将结果转换为 Python 对象

如果同一进程中有足够多的线程,则在到达第 4 步时另一个线程处于事件状态的可能性会增加,从而导致额外的随机延迟。


现在,当涉及到很多进程时,其他因素开始发挥作用,比如内存交换(因为与线程不同,运行相同代码的进程不共享内存)(我很确定还有其他延迟来自很多进程而不是线程竞争资源但不能从我的头顶指出它)。这就是性能变得不稳定的原因。

关于python - 在执行 I/O 绑定(bind)任务时,20 个进程中的 400 个线程优于 4 个进程中的 400 个线程,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/56272639/

相关文章:

c++ - 线程的静态与动态内存分配

c++ - future 是检查单个线程完成的安全方法吗?

c - 根据运行时间分析/代码大小估算功耗

iphone - 滚动性能和UIImage绘制

python - 使用参数进行 K-S 测试并了解结果的问题

python - QDial 禁用完整游览

java - 匿名线程类无法访问非静态实例变量

javascript - array.prototype.includes 与 set.prototype.has 的时间复杂度

python - 谷歌基本 API 的 401 和 403 错误

python - Google Roads API - HTTP 错误 403 : Forbidden