CodeRoadMap
路线图学习路径文章题库资源社区

浏览

首页路线图学习路径知识库题库文章资源社区我的学习
CodeRoadMap

中文编程学习导航:路线图、讲义与题库,进度可同步。

路线图学习路径文章题库社区

© 2026 CodeRoadMap

津ICP备2026012044号-1|coderoadmap@126.com
首页/Python 100 天/63.Python中的并发编程-2

课程目录

  1. 0101.初识Python
  2. 0202.第一个Python程序
  3. 0303.Python语言中的变量
  4. 0404.Python语言中的运算符
  5. 0505.分支结构
  6. 0606.循环结构
  7. 0707.分支和循环结构实战
  8. 0808.常用数据结构之列表-1
  9. 0909.常用数据结构之列表-2
  10. 1010.常用数据结构之元组
  11. 1111.常用数据结构之字符串
  12. 1212.常用数据结构之集合
  13. 1313.常用数据结构之字典
  14. 1414.函数和模块
  15. 1515.函数应用实战
  16. 1616.函数使用进阶
  17. 1717.函数高级应用
  18. 1818.面向对象编程入门
  19. 1919.面向对象编程进阶
  20. 2020.面向对象编程应用
  21. 2121.文件读写和异常处理
  22. 2222.对象的序列化和反序列化
  23. 2323.Python读写CSV文件
  24. 2424.Python读写Excel文件-1
  25. 2525.Python读写Excel文件-2
  26. 2626.Python操作Word和PowerPoint文件
  27. 2727.Python操作PDF文件
  28. 2828.Python处理图像
  29. 2929.Python发送邮件和短信
  30. 3030.正则表达式的应用
  31. 3131.Python语言进阶
  32. 3232-33.Web前端入门
  33. 3334-35.玩转Linux操作系统
  34. 3436.关系型数据库和MySQL概述
  35. 3537.SQL详解之DDL
  36. 3638.SQL详解之DML
  37. 3739.SQL详解之DQL
  38. 3840.SQL详解之DCL
  39. 3941.MySQL新特性
  40. 4042.视图、函数和过程
  41. 4143.索引
  42. 4244.Python接入MySQL数据库
  43. 4345.Hive实战
  44. 4446.Django快速上手
  45. 4547.深入模型
  46. 4648.静态资源和Ajax请求
  47. 4749.Cookie和Session
  48. 4850.制作报表
  49. 4951.日志和调试工具栏
  50. 5052.中间件的应用
  51. 5153.前后端分离开发入门
  52. 5254.RESTful架构和DRF入门
  53. 5355.RESTful架构和DRF进阶
  54. 5456.使用缓存
  55. 5557.接入三方平台
  56. 5658.异步任务和定时任务
  57. 5759.单元测试
  58. 5860.项目上线
  59. 5961.网络数据采集概述
  60. 6062.用Python获取网络资源-1
  61. 6162.用Python解析HTML页面-2
  62. 6263.并发编程在爬虫中的应用
  63. 6363.Python中的并发编程-1
  64. 6463.Python中的并发编程-2
  65. 6563.Python中的并发编程-3
  66. 6664.使用Selenium抓取网页动态内容
  67. 6765.爬虫框架Scrapy简介
  68. 6866.数据分析概述
  69. 6967.环境准备
  70. 7068.NumPy的应用-1
  71. 7169.NumPy的应用-2
  72. 7270.NumPy的应用-3
  73. 7371.NumPy的应用-4
  74. 7472.深入浅出pandas-1
  75. 7573.深入浅出pandas-2
  76. 7674.深入浅出pandas-3
  77. 7775.深入浅出pandas-4
  78. 7876.深入浅出pandas-5
  79. 7977.深入浅出pandas-6
  80. 8078.数据可视化-1
  81. 8179.数据可视化-2
  82. 8280.数据可视化-3
  83. 8381.浅谈机器学习
  84. 8482.k最近邻算法
  85. 8583.决策树和随机森林
  86. 8684.朴素贝叶斯算法
  87. 8785.回归模型
  88. 8886.K-Means聚类算法
  89. 8987.集成学习算法
  90. 9088.神经网络模型
  91. 9189.自然语言处理入门
  92. 9290.机器学习实战
  93. 9391.团队项目开发的问题和解决方案
  94. 9492.Docker容器技术详解
  95. 9593.MySQL性能优化
  96. 9694.网络API接口设计
  97. 9795.使用Django开发商业项目
  98. 9896.软件测试和自动化测试
  99. 9997.电商网站技术要点剖析
  100. 10098.项目部署上线和性能调优
  101. 10199.面试中的公共问题
  102. 102100.补充内容
第 64 课1 天

63.Python中的并发编程-2

在上一课中我们说过,由于 GIL 的存在,CPython 中的多线程并不能发挥 CPU 的多核优势,如果希望突破 GIL 的限制,可以考虑使用多进程。对于多进程

课程内容

Python中的并发编程-2

在上一课中我们说过,由于 GIL 的存在,CPython 中的多线程并不能发挥 CPU 的多核优势,如果希望突破 GIL 的限制,可以考虑使用多进程。对于多进程的程序,每个进程都有一个属于自己的 GIL,所以多进程不会受到 GIL 的影响。那么,我们应该如何在 Python 程序中创建和使用多进程呢?

创建进程

在 Python 中可以基于Process类来创建进程,虽然进程和线程有着本质的差别,但是Process类和Thread类的用法却非常类似。在使用Process类的构造器创建对象时,也是通过target参数传入一个函数来指定进程要执行的代码,而args和kwargs参数可以指定该函数使用的参数值。

from multiprocessing import Process, current_process
from time import sleep


def sub_task(content, nums):
    # 通过current_process函数获取当前进程对象
    # 通过进程对象的pid和name属性获取进程的ID号和名字
    print(f'PID: {current_process().pid}')
    print(f'Name: {current_process().name}')
    # 通过下面的输出不难发现,每个进程都有自己的nums列表,进程之间本就不共享内存
    # 在创建子进程时复制了父进程的数据结构,三个进程从列表中pop(0)得到的值都是20
    counter, total = 0, nums.pop(0)
    print(f'Loop count: {total}')
    sleep(0.5)
    while counter < total:
        counter += 1
        print(f'{counter}: {content}')
        sleep(0.01)


def main():
    nums = [20, 30, 40]
    # 创建并启动进程来执行指定的函数
    Process(target=sub_task, args=('Ping', nums)).start()
    Process(target=sub_task, args=('Pong', nums)).start()
    # 在主进程中执行sub_task函数
    sub_task('Good', nums)


if __name__ == '__main__':
    main()

说明:上面的代码通过current_process函数获取当前进程对象,再通过进程对象的pid属性获取进程ID。在 Python 中,使用os模块的getpid函数也可以达到同样的效果。

如果愿意,也可以使用os模块的fork函数来创建进程,调用该函数时,操作系统自动把当前进程(父进程)复制一份(子进程),父进程的函数会返回子进程的ID,而子进程中的函数会返回,也就是说这个函数调用一次会在父进程和子进程中得到两个不同的返回值。需要注意的是,Windows 系统并不支持函数,如果你使用的是 Linux 或 macOS 系统,可以试试下面的代码。

fork
fork
0
fork
import os

print(f'PID: {os.getpid()}')
pid = os.fork()
if pid == 0:
    print(f'子进程 - PID: {os.getpid()}')
    print('Todo: 在子进程中执行的代码')
else:
    print(f'父进程 - PID: {os.getpid()}')
    print('Todo: 在父进程中执行的代码')

简而言之,我们还是推荐大家通过直接使用Process类、继承Process类和使用进程池(ProcessPoolExecutor)这三种方式来创建和使用多进程,这三种方式不同于上面的fork函数,能够保证代码的兼容性和可移植性。具体的做法跟之前讲过的创建和使用多线程的方式比较接近,此处不再进行赘述。

多进程和多线程的比较

对于爬虫这类 I/O 密集型任务来说,使用多进程并没有什么优势;但是对于计算密集型任务来说,多进程相比多线程,在效率上会有显著的提升,我们可以通过下面的代码来加以证明。下面的代码会通过多线程和多进程两种方式来判断一组大整数是不是质数,很显然这是一个计算密集型任务,我们将任务分别放到多个线程和多个进程中来加速代码的执行,让我们看看多线程和多进程的代码具体表现有何不同。

我们先实现一个多线程的版本,代码如下所示。

import concurrent.futures

PRIMES = [
    1116281,
    1297337,
    104395303,
    472882027,
    533000389,
    817504243,
    982451653,
    112272535095293,
    112582705942171,
    112272535095293,
    115280095190773,
    115797848077099,
    1099726899285419
] * 5


def is_prime(n):
    """判断素数"""
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return n != 1


def main():
    """主函数"""
    with concurrent.futures.ThreadPoolExecutor(max_workers=16) as executor:
        for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)):
            print('%d is prime: %s' % (number, prime))


if __name__ == '__main__':
    main()

假设上面的代码保存在名为example.py的文件中,在 Linux 或 macOS 系统上,可以使用time python example.py命令执行程序并获得操作系统关于执行时间的统计,在我的 macOS 上,某次的运行结果的最后一行输出如下所示。

python example09.py  38.69s user 1.01s system 101% cpu 39.213 total

从运行结果可以看出,多线程的代码只能让 CPU 利用率达到100%,这其实已经证明了多线程的代码无法利用 CPU 多核特性来加速代码的执行,我们再看看多进程的版本,我们将上面代码中的线程池(ThreadPoolExecutor)更换为进程池(ProcessPoolExecutor)。

多进程的版本。

import concurrent.futures

PRIMES = [
    1116281,
    1297337,
    104395303,
    472882027,
    533000389,
    817504243,
    982451653,
    112272535095293,
    112582705942171,
    112272535095293,
    115280095190773,
    115797848077099,
    1099726899285419
] * 5


def is_prime(n):
    """判断素数"""
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return n != 1


def main():
    """主函数"""
    with concurrent.futures.ProcessPoolExecutor(max_workers=16) as executor:
        for number, prime in zip(PRIMES, executor.map(is_prime, PRIMES)):
            print('%d is prime: %s' % (number, prime))


if __name__ == '__main__':
    main()

提示:运行上面的代码时,可以通过操作系统的任务管理器(资源监视器)来查看是否启动了多个 Python 解释器进程。

我们仍然通过time python example.py的方式来执行上述代码,运行结果的最后一行如下所示。

python example09.py 106.63s user 0.57s system 389% cpu 27.497 total

可以看出,多进程的版本在我使用的这台电脑上,让 CPU 的利用率达到了将近400%,而运行代码时用户态耗费的 CPU 的时间(106.63秒)几乎是代码运行总时间(27.497秒)的4倍,从这两点都可以看出,我的电脑使用了一款4核的 CPU。当然,要知道自己的电脑有几个 CPU 或几个核,可以直接使用下面的代码。

import os

print(os.cpu_count())

综上所述,多进程可以突破 GIL 的限制,充分利用 CPU 多核特性,对于计算密集型任务,这一点是相当重要的。常见的计算密集型任务包括科学计算、图像处理、音视频编解码等,如果这些计算密集型任务本身是可以并行的,那么使用多进程应该是更好的选择。

进程间通信

在讲解进程间通信之前,先给大家一个任务:启动两个进程,一个输出“Ping”,一个输出“Pong”,两个进程输出的“Ping”和“Pong”加起来一共有50个时,就结束程序。听起来是不是非常简单,但是实际编写代码时,由于多个进程之间不能够像多个线程之间直接通过共享内存的方式交换数据,所以下面的代码是达不到我们想要的结果的。

from multiprocessing import Process
from time import sleep

counter = 0


def sub_task(string):
    global counter
    while counter < 50:
        print(string, end='', flush=True)
        counter += 1
        sleep(0.01)

        
def main():
    Process(target=sub_task, args=('Ping', )).start()
    Process(target=sub_task, args=('Pong', )).start()


if __name__ == '__main__':
    main()

上面的代码看起来没毛病,但是最后的结果是“Ping”和“Pong”各输出了50个。再次提醒大家,当我们在程序中创建进程的时候,子进程会复制父进程及其所有的数据结构,每个子进程有自己独立的内存空间,这也就意味着两个子进程中各有一个counter变量,它们都会从0加到50,所以结果就可想而知了。要解决这个问题比较简单的办法是使用multiprocessing模块中的Queue类,它是可以被多个进程共享的队列,底层是通过操作系统底层的管道和信号量(semaphore)机制来实现的,代码如下所示。

import time
from multiprocessing import Process, Queue


def sub_task(content, queue):
    counter = queue.get()
    while counter < 50:
        print(content, end='', flush=True)
        counter += 1
        queue.put(counter)
        time.sleep(0.01)
        counter = queue.get()


def main():
    queue = Queue()
    queue.put(0)
    p1 = Process(target=sub_task, args=('Ping', queue))
    p1.start()
    p2 = Process(target=sub_task, args=('Pong', queue))
    p2.start()
    while p1.is_alive() and p2.is_alive():
        pass
    queue.put(50)


if __name__ == '__main__':
    main()

提示:multiprocessing.Queue对象的get方法默认在队列为空时是会阻塞的,直到获取到数据才会返回。如果不希望该方法阻塞以及需要指定阻塞的超时时间,可以通过指定block和timeout参数进行设定。

上面的代码通过Queue类的get和put方法让三个进程(p1、p2和主进程)实现了数据的共享,这就是所谓的进程间的通信,通过这种方式,当Queue中取出的值已经大于等于50时,p1和p2就会跳出while循环,从而终止进程的执行。代码第22行的循环是为了等待p1和p2两个进程中的一个结束,这时候主进程还需要向Queue中放置一个大于等于50的值,这样另一个尚未结束的进程也会因为读到这个大于等于50的值而终止。

进程间通信的方式还有很多,比如使用套接字也可以实现两个进程的通信,甚至于这两个进程并不在同一台主机上,有兴趣的读者可以自行了解。

总结

在 Python 中,我们还可以通过subprocess模块的call函数执行其他的命令来创建子进程,相当于就是在我们的程序中调用其他程序,这里我们暂不探讨这些知识,有兴趣的读者可以自行研究。

对于Python开发者来说,以下情况需要考虑使用多线程:

  1. 程序需要维护许多共享的状态(尤其是可变状态),Python 中的列表、字典、集合都是线程安全的(多个线程同时操作同一个列表、字典或集合,不会引发错误和数据问题),所以使用线程而不是进程维护共享状态的代价相对较小。
  2. 程序会花费大量时间在 I/O 操作上,没有太多并行计算的需求且不需占用太多的内存。

那么在遇到下列情况时,应该考虑使用多进程:

  1. 程序执行计算密集型任务(如:音视频编解码、数据压缩、科学计算等)。
  2. 程序的输入可以并行的分成块,并且可以将运算结果合并。
  3. 程序在内存使用方面没有任何限制且不强依赖于 I/O 操作(如读写文件、套接字等)。
← 上一课63.Python中的并发编程-1下一课 →63.Python中的并发编程-3