【问题标题】:400 threads in 20 processes outperform 400 threads in 4 processes while performing an I/O-bound task在执行 I/O 密集型任务时,20 个进程中的 400 个线程优于 4 个进程中的 400 个线程
【发布时间】:2019-05-23 09:55:36
【问题描述】:

实验代码

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

但测试结果表明,每个包含 100 个线程的 4 个进程的性能始终比每个包含 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

问题

为什么即使我只有 4 个 CPU,20 进程 x 20 线程的性能也比 4 进程 x 100 线程好?

【问题讨论】:

  • 你真的在测量urllib.request.urlopen(url),这会导致大量的系统调用、线程(在操作系统意义上)移动到等待状态等等......
  • 该方法是纯 IO,因此内核数量并不重要。它正在做一个 Web 应用负载测试器会做的事情。大多数时候该方法是什么都不做,等待操作系统完成网络IO,或者远程服务器响应。时间主要受远程服务器、缓存、网络带宽和网卡性能的影响
  • @PanagiotisKanavos 在这种情况下,我希望 4 个进程 x 100 个线程以及 20 个进程 x 20 个线程执行。那么为什么 4 个进程 x 100 个线程的性能始终比 20 个进程 x 20 个线程差?此外,观察到 1 个进程 x 400 个工作人员的性能最差,这确实表明在物理时间的任何时间点仅使用一个内核会导致性能最差。
  • @SusamPal 为什么要这么期待呢?您首先测量的是什么?这不是 CPU 时间,也不是任何类型的本地性能。你检查你的路由器性能了吗?你的带宽?您的代码不执行任何与 CPU 相关的操作,因此您不是在测量线程性能,而是在测量网络的性能
  • @SusamPal 实际上,进程限制正是导致具有 100 个线程的 4 个进程的工作效率低于 20x20 的原因。如果每个进程都获得相同的限制,那么这 100 个线程就会互相妨碍。

标签: python multithreading performance multiprocessing gil


【解决方案1】:

您的任务受 I/O 限制而非 CPU 限制:线程大部分时间都处于睡眠状态等待网络数据等,而不是使用 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 步时,另一个线程很可能处于活动状态,从而导致额外的随机延迟。


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

【讨论】:

  • 我知道添加比 CPU 更多的线程在这里可以工作,因为该任务是 I/O 绑定的。我感到困惑的是为什么进程之间的线程分布很重要。为什么在 20 个进程内运行 400 个线程(即每个进程 20 个线程)始终比在 4 个进程内运行 400 个线程(即每个进程 100 个线程)表现更好?
  • @SusamPal 这很可能是 Python GIL 的开销。
  • 不确定问题的核心是否已得到解决:根据您的回答,对于相同数量的线程,20 个进程需要 更多 开销,但 OP 观察到的线程数相同是相反的。
  • @javadba OP 没有“相同数量的线程的 4 个进程”,它们有 4 个进程,每个进程有更多线程。我展示了每个进程的更多线程如何增加开销。
  • 是的,我们确实拥有相同数量的线程:在所有情况下都是 400。但至于“每个进程更多的线程会增加开销”这可能是正确的 - 但也有不利的一面:拥有更多进程需要将数据复制到每个进程。对于更多进程/每个进程更少的线程,启动成本和所需的整体内存更高。您在回答中声称的是,当每个进程有更多线程时,为每个进程切换 GIL 的成本会更高。对于适用于所有场景的更好/更高性能的方法,没有一个正确的答案。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-04-19
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多