Python并发:多线程与多进程



本篇将会涉及:

  • 线程与多线程
  • 进程与多进程
  • 多线程并发下载图片
  • 多进程并发提高数字运算

关于并发

在计算机编程领域,并发编程是一个很常见的名词和功能了,其实并发这个理念,最初是源于铁路和电报的早期工作。比如在同一个铁路系统上如何安排多列火车,保证每列火车的运行都不会发生冲突。

后来在20世纪60年代,学术界对计算机的并行计算开始进行研究,再后来,操作系统能够进行并发的处理任务,编程语言能够为程序实现并发的功能。

线程与多线程

什么是线程

一个线程可以看成是一个有序的指令流(完成特定任务的指令),并且可以通过操作系统来调度这些指令流。
线程通常位于进程程里面,由一个程序计数器、一个堆栈和一组寄存器以及一个标识符组成。这些线程是处理器可以分配时间的最小执行单元。

线程之间是可以共享内存并且互相通信的。但是当两个线程之间开始共享内存,就无法保证线程执行的顺序,这可能导致程序错误,或者产生错误的结果。这个问题我们日后会专门提及。

下面这个图片展示了多个线程在多个CPU中的存在方式:

线程的类型

在一个典型的操作系统里面,一般会有两种类型的线程:

  • 用户级线程:我们能够创建、运行和杀死的线程;
  • 内核级线程:操作系统运行的低级别线程;

Python工作在用户级线程上,我们介绍的内容也主要是在用户级的线程上运行的。

 什么是多线程

现在的CPU基本上都是多线程的CPU,比如我们随意从京东上找一个Inter的酷睿i5处理器,看看它的产品规格:

这些CPU能够同时运行多个线程来处理任务,其实从本质上来说,这些CPU是利用一个能够在多个线程之间快速切换的单个内核来完成多线程的运行的,切换线程的速度足够快,所以我们并不会感觉到。但实质上,它们并不是同时运行的。

为了形象的理解多线程,我们来回忆一个场景。
在大学时代,期末的时候,有些科目的老师为了不为难大家,把考试设为开卷考试,不知道大家面对开卷考试的时候,做题的顺序是怎样的?

在单线程的工作模式下,我们从选择题到填空题到简答题再到分析题,一个一个按顺序的写。
遇到一个特别难的题目,我们就要翻书翻资料了,当然既然是开卷考试,有些题目的答案就不可能直接出现在教科书中,那么我们就要花费更多的时间来找答案,直到考试结束,因为某个难题耗费的翻书时间太多,导致后面一些简单的题目也没用做,嗯,开卷都写不完试卷,挂科名额就给你了。

而在多线程的工作模式下,我们也是按顺序写,但是遇到难题时,我们会稍微从书中找找答案,如果没找到,就先做下面的题目,把会做的题目做好,做好了容易的题目,再回到那个难题上,仔细从书中的蛛丝马迹中找答案。

在这个例子里面,我们只是一个人来完成,如果想要更快地完成考试,就得跟其他同学通力合作和分工了。

让我们看看线程的一些优点:

  • 多线程能够有效提升I/O阻塞型程序的效率;
  • 与进程相比,占用的系统资源少;
  • 线程间能够共享资源,方便进行通信;

线程还有一些缺点:

  • Python中有全局解释器锁(GIL)的限制;
  • 虽然线程之间能够进行通信,但是容易导致程序结果出错,使用的时候必须小心;
  • 在多线程之间切换的计算代价高,会导致程序的整体性能下降。

进程与多进程

进程在本质上与线程非常相似,进程几乎可以完成线程能够完成的任何事情。

按照上面开卷考试的例子,如果我们和室友组成一个小团伙,那么我们就有四个CPU(4个人),四个人分别写和找不同的答案,这样考试的效率会提高很多。

一个进程里面,包含一个主线程,还可以生成很多子线程,每个线程都包含自己的寄存器组合堆栈。如果有需要的话,可以将它们组成多线程。

下面是单线程单进程和多线程单进程的示例:

进程的特性

一个进程通常包含以下的内容:

  • 进程ID,进程组ID,用户ID,组ID
  • 环境
  • 工作目录
  • 程序指令
  • 寄存器
  • 堆栈
  • 文件描述
  • 进程间通信工具
  • 等等……

进程有以下优点:

  • 更好地利用多核处理器;
  • 在处理CPU密集型任务时比多线程要好;
  • 可以通过多进程来避免全局解释器锁(GIL)的局限;
  • 崩溃的进程不会导致整个程序的崩溃;

同时,还有以下缺点:

  • 进程之间没有共享资源;
  • 进程需要消耗更多的内存;

多进程

在Python中我们可以使用多线程或者多进程的方式来运行我们的代码以改进传统的单线程方式的性能。
在单核的CPU上可以使用多线程提高处理能力,但是在现在的计算机CPU中,多核处理器早已普及,为了有效的利用机器的资源,我们有必要使用多进程来发挥机器的价值。
一个CPU内核将任务分配给其他CPU:

通过Python的进程处理模块multiprocessing,我们可以有效的利用机器上所有的处理器,这有助于我们在处理CPU密集型任务时获得更高的性能。

使用multiprocessing模块,查看我们机器上的CPU核心数量:

结果返回一个数字,为CPU核心数。

多进程不仅能够提高我们的计算机的利用率,还能够避免全局解释器锁的限制,一个潜在的缺点是多进程间不能进行共享和通信(可以通过其他手段实现),但是这个缺点同时也使多进程更加容易使用和避免出现崩溃。

Python的局限性

在文章的前面,我们谈到了在Python中存在的全局解释器锁GIL的局限性。那GIL到底是个什么东西?
GIL本质上是一个互斥锁,由Python的解析器CPython带来,它可以防止多个线程同时执行Python代码。 它是一个只能由一个线程保持的锁,如果你想要一个线程去执行代码,那么在它执行代码之前,首先必须获得这个锁。 这样做的一个好处是,当它被锁定的时候,没有别的进程可以同时运行代码,一定程度上避免了线程间的冲突:

上面这个图说明了多个线程如何被GIL阻塞。每个线程必须等待获取到GIL才能进行下一步的运行,然后再释放GIL。线程之间使用随机循环的方式,所以并不能控制和保证哪个线程会先得到GIL。

这样的设计似乎很反人类,而这也是很多人诟病Python的地方。但是,这个设计确实是保证的多线程之间的内存安全。

现在我们已经了解了线程和进程,以及Python的一些限制,现在是时候了解一下我们如何在应用程序中使用多线程多进程,以提高程序的速度。

并发文件下载

毫无疑问的,展现多线程优点的一个例子就是使用多线程来下载多个图片或者文件,由于I/O的阻塞性质,下载任务可能是多线程最佳的运用场景了。

http://tool.bitefu.net/jiari/data/2017.txt是一个提供2017年所有节假日的文本文件:

我们访问10次,获得10次文本文件,然后保存在本地。
先看看一个普通的爬取:

我们引入了模块urllib.request,然后创建了一个函数downloadImage()用于下载文件,创建了一个函数main()用于对下载函数进行遍历20次。

耗时4秒多。

 

下面看看使用多线程的:

程序的前部分大同小异,后面我们创建了一个threads列表,,然后遍历10次,创建一个新的线程对象,将其添加到threads列表中,然后启动该线程。

最后,我们通过遍历我们的threads列表来调用我们的线程,然后调用join()方法在每个线程上,这确保我们在下载完文件之前,不会执行剩下的代码。

运行代码,可以发现程序几乎同时启动了10个下载任务,然后在图片下载完成后,再打印出来。
耗时0.1秒,效率提高很多。

但是需要注意的是,在网络中进行文件IO,还需要考虑网络状况的影响,不同的网络状况下,完成的效率也不一样。

并发数字运算

I/O密集型的任务适合于多线程,而CPU密集型的任务则适合用多进程。
在下面的例子里,我们将找出100万个20000到100000000之间随机数的质数。

顺序运算:

耗时18秒。

 

多进程运算:

耗时11秒。

我们分别按顺序循环100万遍和使用多进程的进程池循环100万次,多进程模式下速度提升了近7秒。
大家可以自己测试一下效果。

不知道大家对Python的多线程和多进程还有什么疑问,欢迎留言讨论。

如果本文对你有帮助,请打赏州的先生,鼓励他继续写作!

点赞
  1. 说道:

    可否解释一下,map_async(),map(),apply_async(),apply()的区别?

    1. zmister说道:

      最好的解释是源码里面的解释:map()的解释是:Apply func to each element in iterable, collecting the results in a list that is returned.将数组中的每一个元素都应用到函数中,返回一个结果列表;map_async()是map()的异步版本;apply():Equivalent of func(*args, **kwds),也就是相当于调用一个函数;而apply_async()又是apply()的异步版本。map()与apply()的区别在于,apply接收一个参数,将其传递给函数,map()接收一个参数列表,迭代传递给函数。

发表评论

电子邮件地址不会被公开。