线程和多进程模块有什么区别?

208

我正在学习如何使用Python中的 threadingmultiprocessing 模块并行运行代码以提高效率。

我发现很难理解 threading.Thread() 对象和 multiprocessing.Process() 对象之间的区别,可能是因为我没有相应的理论背景知识。

同时,我也不太清楚如何实例化一个作业队列,只有其中的 4 个作业在并行运行,而其余作业等待资源释放后再执行。

我觉得文档中的示例很清晰,但不够详尽;一旦涉及到稍微复杂一点的问题,我就会接收到很多奇怪的错误(比如无法pickle某个方法等)。

那么,我应该在什么情况下使用 threading 模块或 multiprocessing 模块呢?

你能为我提供一些资源链接,以便了解这两个模块背后的概念和如何正确地使用它们来完成复杂的任务吗?


还有更多,还有Thread模块(在Python 3.x中称为_thread)。老实说,我自己从来没有理解过它们之间的区别... - Dunno
3
正如Thread/_thread文档明确指出的那样,它是“低级原语”。您可以使用它来构建自定义同步对象,控制线程树的加入顺序等。如果您无法想象为什么需要使用它,请不要使用它,而应坚持使用threading - abarnert
@abarnert的答案非常棒,而且也被接受了。我想分享一个优秀的链接,作为对GIL的很好补充:http://eli.thegreenplace.net/2012/01/16/python-parallelizing-cpu-bound-tasks-with-multiprocessing/ - Bruce
7个回答

344

Giulio Franco所说的是关于多线程和多进程通常情况下的真相。

然而,Python*存在一个额外的问题:全局解释器锁定,防止同一进程中的两个线程同时运行Python代码。这意味着如果您有8个核心,并将代码更改为使用8个线程,则它将无法使用800%的CPU并以8倍的速度运行;它将使用相同的100%的CPU并以相同的速度运行。(实际上,它会运行得稍微慢一些,因为即使没有任何共享数据,线程也会增加额外的开销,但现在先忽略这一点。)

这里有例外情况。如果您的代码的重计算实际上不是在Python中进行的,而是在某个具有自定义C代码(如numpy应用程序)的库中进行适当的GIL处理,则可以从线程获得预期的性能优势。如果重计算由一些子进程完成并等待,则也是如此。

更重要的是,有些情况下这并不重要。例如,网络服务器大部分时间都在从网络中读取数据包,GUI应用程序大部分时间都在等待用户事件。在网络服务器或GUI应用程序中使用线程的一个原因是允许您执行长时间运行的“后台任务”,而不会停止主线程继续服务网络数据包或GUI事件。Python线程完全可以胜任此任务。(从技术上讲,这意味着Python线程提供了并发性,尽管它们没有提供核心并行性。)

但是,如果您在纯Python中编写CPU密集型程序,则使用更多线程通常是没有帮助的。

使用单独的进程没有GIL的问题,因为每个进程都有自己独立的GIL。当然,您仍然需要权衡线程和进程之间的所有相同的折衷,就像在任何其他语言中一样——在进程之间共享数据比在线程之间共享数据更加困难和昂贵,运行大量进程或经常创建和销毁它们可能成本很高等等。但是,GIL对于进程的平衡影响很大,在C或Java中不是这样的。因此,在Python中您将更频繁地使用多进程。


同时,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%的CPU,并且添加更多线程只会使它变得更慢,那么你遇到了GIL问题,所以你需要切换到进程。你只需要更改第一行即可:
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:

唯一的真正注意事项是,您的作业参数和返回值必须是可pickle(并且不需要太多时间或内存来pickle)才能在跨进程中使用。通常这不是问题,但有时会出现问题。

但是如果你的任务无法自包含呢?如果你能够通过设计代码,让它以传递消息的方式从一个任务传递到另一个任务,那就还是相当容易的。你可能需要使用 threading.Threadmultiprocessing.Process 代替依赖池。而且你将需要显式地创建 queue.Queuemultiprocessing.Queue 对象。(还有很多其他的选项——管道,套接字,带有锁的文件等等,但关键是,如果执行器的自动魔法不足,你必须手动做一些事情。)

但是如果你甚至不能依赖于消息传递呢?如果你需要两个任务都改变同一个结构,并看到彼此的改变,那么你将需要进行手动同步(锁,信号量,条件等),并且如果你想使用进程,还需要显式共享内存对象。这时多线程(或多进程)就会变得困难起来。如果可以避免,那太好了;如果不能,你将需要阅读更多的内容,超过 SO 答案所能提供的范围。


根据一条评论,您想了解Python中的线程(thread)和进程(process)之间的区别。如果您阅读Giulio Franco的答案以及我的回答和我们所有的链接,那么应该涵盖了所有内容...但是总结肯定会有帮助,因此我们来看看:

  1. 线程默认共享数据;进程则不会。
  2. 由于第一点,进程之间发送数据通常需要对其进行pickle和unpickle操作。**
  3. 由于第一点的另一个后果,直接在进程之间共享数据通常需要将其放入像Value、Array和ctypes类型这样的底层格式中。
  4. 进程不受GIL的限制。
  5. 在某些平台(主要是Windows),创建和销毁进程的成本更高。
  6. 进程存在一些额外的限制,其中有些在不同的平台上也有所不同。有关详细信息,请参见编程指南
  7. threading模块没有multiprocessing模块的某些功能。(您可以使用multiprocessing.dummy在线程之上获取大部分缺失的API,或者使用像concurrent.futures这样的高级模块而不必担心它。)

* 实际上有这个问题的不是Python语言本身,而是CPython,即该语言的“标准”实现方式。像Jython这样的一些其他实现方式没有全局解释器锁(GIL)。

** 如果您在多进程中使用fork启动方法 - 在大多数非Windows平台上都可以这样做 - 每个子进程获取父进程在启动子进程时拥有的所有资源,这也是向子进程传递数据的另一种方法。


谢谢,但我不太确定我理解了所有内容。无论如何,出于学习目的和出于幼稚的线程使用,我正在尝试做一些修改(同时启动1000多个线程,每个线程都调用一个外部应用程序...这会使CPU饱和,但速度提高了两倍)。我认为巧妙地管理线程可能真的可以提高我的代码速度。 - lucacerone
4
如果你的代码大部分时间都在等待外部程序的返回,那么使用线程确实会对其有所裨益。很好的观点。让我修改回答来解释一下。 - abarnert
3
@LucaCerone: 同时,请问有哪些部分您不理解?如果不知道您的起点水平,很难写出好的答案......但是如果有一些反馈,也许我们可以想出对您和未来读者有帮助的内容。 - abarnert
6
你应该阅读多进程的PEP文档这里,它提供了线程和多进程的时间和示例比较。 - mr2ert
1
@LucaCerone:如果方法绑定的对象没有任何复杂状态,那么解决pickle问题的最简单方法是编写一个愚蠢的包装函数来生成对象并调用其方法。如果它有复杂状态,那么您可能需要使其可picklable(这很容易;pickle文档中有解释),然后最坏的情况下,您的愚蠢包装器是 def wrapper(obj, *args): return obj.wrapper(*args) - abarnert
显示剩余6条评论

51

一个进程内可以存在多个线程。 属于同一进程的线程共享同一块内存区域(可以读写相同的变量,并且彼此干扰)。 相反,不同的进程存在于不同的内存区域,并且每个进程都有自己的变量。为了通信,进程必须使用其他通道(文件、管道或套接字)。

如果您想并行计算,您可能需要使用多线程,因为您可能希望线程在同一块内存上进行协作。

就性能而言,线程比进程更快地创建和管理(因为操作系统不需要分配一个全新的虚拟内存区域),并且线程间通信通常比进程间通信更快速。但是编程线程会更加困难。线程可能会相互干扰并且可以写入彼此的内存,但这种情况发生的方式并不总是明显的(因为多种因素,主要是指令重排序和内存缓存),因此您需要同步原语来控制对变量的访问。


16
这篇文章没有提供关于全局解释器锁(GIL)的一些非常重要的信息,这使得它存在误导性。 - abarnert
1
@mr2ert:是的,那就是要点了。:) 但实际上比这复杂一些,这也是我写另一个答案的原因。 - abarnert
3
我原本以为我已经评论说@abarnert是正确的,并且在回答这里时忘记了GIL。所以这个答案是错误的,你不应该给它点赞。 - Giulio Franco
9
我将翻译为:我之所以给这个答案投了反对票,是因为它仍然没有回答Python中的threadingmultiprocessing有何区别。 - Antti Haapala -- Слава Україні

14

Python文档引用

我已经突出了关于进程与线程以及GIL的关键Python文档引用: 什么是CPython中的全局解释器锁(GIL)?

进程 vs 线程实验

我进行了一些基准测试,以更具体地展示差异。

在基准测试中,我计时了在8个超线程CPU上使用各种线程数量的CPU和IO绑定工作。每个线程提供的工作总是相同的,因此更多的线程意味着更多的总工作量。

结果如下:

enter image description here

画数据

结论:

  • 对于CPU密集型工作,多进程始终更快,可能是由于GIL的原因。

  • 对于IO密集型工作,两者速度完全相同。

  • 线程仅扩展到约4倍,而不是预期的8倍,因为我在一台8超线程机器上。

    与C POSIX CPU密集型工作相比,后者达到了预期的8倍加速: time(1)的输出中'real','user'和'sys'是什么意思?

    待办事项:我不知道原因,必须有其他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 upstream + plotting code on same directory

在Ubuntu 18.10、Python 3.6.7上测试通过,在联想ThinkPad P51笔记本电脑上使用,配备CPU:Intel Core i7-7820HQ CPU(4个核心/8个线程),RAM:2x Samsung M471A2K43BB1-CRC(2x 16GiB),SSD:Samsung MZVLB512HAJQ-000L7(3,000 MB/s)。

可视化给定时间正在运行的线程

这篇文章https://rohanvarma.me/GIL/告诉我,你可以在threading.Threadtarget=参数和multiprocessing.Process中运行回调函数,以便在每个线程被调度时运行该回调函数。

这允许我们精确地查看每个时间运行的线程。当这样做时,我们会看到类似于以下内容(我制作了这个特定的图表):

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

这将表明:

  • 线程完全由GIL串行化
  • 进程可以并行运行

5
我相信这个链接以优雅的方式回答了您的问题。
简而言之,如果您的一个子问题必须等待另一个完成,多线程很好(例如在I/O密集型操作中);相反,如果您的子问题确实可以同时发生,建议使用多进程。但是,您不会创建超过您核心数的进程。

2
这里是一些有关于 Python 2.6.x 的性能数据,对于线程在输入/输出密集场景中比多进程更高效的观点提出了质疑。这些结果来自一个拥有40个处理器的 IBM System x3650 M4 BD。
输入/输出密集处理:进程池优于线程池。
>>> 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

CPU密集型处理:进程池比线程池表现更好。
>>> 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

这些并不是严格的测试,但它们告诉我,在与线程相比较时,多进程并不完全性能低下。
上述测试中在交互式Python控制台中使用的代码。
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')

1
我已经使用了您的代码(删除了glob部分),并在Python 2.6.6中发现了这些有趣的结果: >>> do_work(50, 300, 'thread', 'fileio') --> 237.557 毫秒 >>> do_work(50, 300, 'process', 'fileio') --> 323.963 毫秒 >>> do_work(50, 2000, 'thread', 'square') --> 232.082 毫秒 >>> do_work(50, 2000, 'process', 'square') --> 282.785 毫秒 - Alan Garrido

0

区别

线程 进程
使用本机线程,而非本机进程。
线程属于进程。
共享内存,非进程间通信。
受全局解释器锁限制,非真正的并行执行。
适用于I/O绑定任务,非CPU绑定任务。
使用本机进程,而非本机线程。
进程具有线程和子进程。
重量级且启动较慢,非轻量级且启动较快。
进程间通信,而非共享内存。
适用于CPU绑定任务,可能不适用于I/O绑定任务。

使用方法

  • 何时使用线程
    • 对于IO密集型任务使用线程
      • 从硬盘读取或写入文件。
      • 读取或写入标准输出、输入或错误(stdin、stdout、stderr)。
      • 打印文档。
      • 下载或上传文件。
      • 查询服务器。
      • 查询数据库。
      • 拍照或录制视频。
  • 何时使用进程
    • 对于CPU密集型任务使用进程
      • 计算分形中的点。
      • 估算圆周率。
      • 质因数分解。
      • 解析HTML、JSON等文档。
      • 处理文本。
      • 运行模拟。

来源:Python中的线程与进程


-5

好的,Giulio Franco已经回答了大部分问题。我将进一步阐述消费者-生产者问题,我认为这将为您使用多线程应用程序的解决方案指明正确的方向。

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 ++,而是伪代码(或者是用类似C语言的语法编写的大多数动态类型语言的代码)。话虽如此,我认为编写类似Python的伪代码对于教授Python用户更有用。(特别是因为类似Python的伪代码通常会变成可运行的代码,或者至少接近可运行的代码,而这对于类似C的伪代码很少是真实的...) - abarnert
我已将其重写为类似Python的伪代码(还使用了面向对象和传递参数而不是使用全局对象);如果您认为这会使事情变得不太清晰,请随意恢复原样。 - abarnert
另外值得注意的是,Python标准库中内置了一个同步队列(synchronized queue)[http://docs.python.org/3/library/queue.html],它封装了所有这些细节,并且其线程和进程池API进一步抽象了这些细节。理解同步队列在底层如何工作肯定是值得的,但你很少需要自己编写一个。 - abarnert

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接