# 在做python头歌实验的时候遇到了新的知识点,遂记录
任务描述
本关任务:了解多线程和多进程的优缺点和应用场景。
相关知识
为了完成本关任务,你需要掌握:
- 线程和进程的优缺点;
- 线程和进程的应用场景。
多线程比多进程性能高?
误导!
应该说,多线程比多进程成本低,但性能更低。
在 UNIX 环境,多进程调度的开销和多线程调度的开销没有显著区别。就是说, UNIX 进程调度效率是很高的。内存消耗方面,二者只差全局数据区,现在内存都很便宜,服务器内存动辄若干 G,根本不是问题。
-
多进程是立体交通系统,虽然造价高,上坡下坡多耗点油,但是不堵车;
-
多线程是平面交通系统,造价低,但红绿灯太多,老堵车。
我们现在都开跑车,油(主频)有的是,不怕上坡下坡,就怕堵车。
线程和进程的优缺点
我们介绍了多进程和多线程,这是实现多任务最常用的两种方式。现在,我们来讨论一下这两种方式的优缺点。
多进程:
多进程优点:
-
每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系;
-
通过增加 CPU,就很容易扩充性能;
-
可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系;
-
每个子进程都有 2GB 地址空间和相关资源,总体能够达到的性能上限非常大。
多进程缺点:
-
逻辑控制复杂,需要和主程序交互;
-
需要跨进程边界,如果有大量数据需要传送,就不太好,适合少量数据传送、密集运算,多进程调度开销比较大;
-
最好是多进程和多线程结合,即根据实际的需要,每个 CPU 开启一个子进程,这个子进程开启多线程,可以为若干同类型的数据进行处理。当然你也可以利用多线程 + 多 CPU + 轮询方式来解决问题……;
-
方法和手段是多样的,关键是自己看起来,实现方便又能够满足要求,代价也合适。
多线程:
多线程的优点:
-
无需跨进程边界;
-
程序逻辑和控制方式简单;
-
所有线程可以直接共享内存和变量等;
-
线程方式消耗的总资源比进程方式好。
多线程缺点:
-
每个线程与主程序共用地址空间,受限于 2GB 地址空间;
-
线程之间的同步和加锁控制比较麻烦;
-
一个线程的崩溃可能影响到整个程序的稳定性;
-
到达一定的线程数程度后,即使再增加 CPU 也无法提高性能,例如 Windows Server 2003,大约是 1500 个左右的线程数就快到极限了(线程堆栈设定为 1M ),如果设定线程堆栈为 2M ,还达不到 1500 个线程总数;
-
线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的 CPU。
线程切换
无论是多进程还是多线程,只要数量一多,效率肯定上不去,为什么呢?
我们打个比方,假设你不幸正在准备中考,每天晚上需要做语文、数学、英语、物理、化学这 5 科的作业,每项作业耗时 1 小时。
如果你先花 1 小时做语文作业,做完了,再花 1 小时做数学作业,这样,依次全部做完,一共花 5 小时,这种方式称为单任务模型,或者批处理任务模型。
假设你打算切换到多任务模型,可以先做 1 分钟语文,再切换到数学作业,做 1 分钟,再切换到英语,以此类推,只要切换速度足够快,这种方式就和单核 CPU 执行多任务是一样的了,以幼儿园小朋友的眼光来看,你就正在同时写 5 科作业。
但是,切换作业是有代价的,比如从语文切到数学,要先收拾桌子上的语文书本、钢笔(这叫保存现场),然后,打开数学课本、找出圆规直尺(这叫准备新环境),才能开始做数学作业。操作系统在切换进程或者线程时也是一样的,它需要先保存当前执行的现场环境(CPU 寄存器状态、内存页等),然后,把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,但是也需要耗费时间。如果有几千个任务同时进行,操作系统可能就主要忙着切换任务,根本没有多少时间去执行任务了,这种情况最常见的就是硬盘狂响,点窗口无反应,系统处于假死状态。
所以,多任务一旦多到一个限度,就会消耗掉系统所有的资源,结果效率急剧下降,所有任务都做不好。
线程和进程的应用场景
是否采用多任务的第二个考虑是任务的类型。我们可以把任务分为计算密集型和 IO 密集型。
计算密集型任务的特点是要进行大量的计算,消耗 CPU 资源,比如计算圆周率、对视频进行高清解码等等,全靠 CPU 的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU 执行任务的效率就越低。所以,要最高效地利用 CPU,计算密集型任务同时进行的数量,应当等于 CPU 的核心数。
计算密集型任务由于主要消耗 CPU 资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用 C 语言编写。
第二种任务的类型是 IO 密集型,涉及到网络、磁盘 IO 的任务都是 IO 密集型任务。这类任务的特点是 CPU 消耗很少,任务的大部分时间都在等待 IO 操作完成(因为 IO 的速度远远低于 CPU 和内存的速度)。对于 IO 密集型任务,任务越多,CPU 效率越高,但也有一个限度。常见的大部分任务都是 IO 密集型任务,比如 Web 应用。
IO 密集型任务执行期间,99% 的时间都花在 IO 上,花在 CPU 上的时间很少。因此,用运行速度极快的 C 语言并不能提升运行效率。对于 IO 密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C 语言最差。
编程要求
在右侧编辑器 Begin-End 区间补充代码,使用多线程或者多进程,求一个整数数组里的最大值。
测试说明
测试数据第一行一个整数n
表示整数数组的长度,第二行n
个整数表示数组里的每个元素,你只需要输出一个数字,表示数组里的最大值即可。
测试输入: 5
8 9 12 8 0
预期输出: 12
实验代码
import math
from multiprocessing import cpu_count
from multiprocessing import Pool
N = int(input())
a = list(map(int, input().split()))
# split() 方法:将输入的字符串分割成多个子字符串,通常是按空格分割。如果需要按其他分隔符分割,可以传递参数给 split() 方法
# map(int, ...) 函数:map 函数接受一个函数和一个可迭代对象作为参数。在这个例子中,它接受 int 函数和由 split() 返回的子字符串列表。map 函数会对列表中的每个元素执行 int 函数,即将每个字符串转换为整数
# list(...) 函数:将 map 函数返回的映射对象转换成列表
def howMany(T):
ans = 0;
for i in range(T[0] - 1, T[1]):
ans = max(ans, a[i])
return ans
# 对整个数字空间N进行分段CPU_COUNT
def seprateNum(N, CPU_COUNT):
list = [[i * (N // CPU_COUNT) + 1, (i + 1) * (N // CPU_COUNT)] for i in range(0, CPU_COUNT)]
list[0][0] = 1
if list[CPU_COUNT - 1][1] < N:
list[CPU_COUNT - 1][1] = N
return list
if __name__ == '__main__':
# 多进程
#********** Begin *********#
CPU_COUNT = cpu_count()
ranges = seprateNum(N, CPU_COUNT)
# with ... as p: 这是一个上下文管理器(context manager)的用法,确保在使用完进程池后,自动关闭并清理进程池。上下文管理器的好处是,它可以自动处理资源的分配和释放,避免手动关闭进程池。
with Pool(CPU_COUNT) as p: # 使用 Pool 创建一个进程池,并行计算每个分段的最大值
results = p.map(howMany, ranges) # p.map: map 方法将一个函数应用到一个可迭代对象的每一个元素上,这里是将 howMany 函数应用到 ranges 列表的每一个元素上。map 方法会将任务分配给进程池中的多个进程并行执行。
# map 方法会返回一个列表 results,其中包含每个区间的最大值。这些最大值是各个进程计算出来的结果,map 方法会在所有进程完成任务后收集这些结果并返回
# howMany: 这是我们定义的函数,用于计算数组指定区间内的最大值。它接收一个区间范围作为参数,并返回该区间内的最大值。
# ranges: 这是一个列表,其中每个元素都是一个区间(子数组)的起始和结束索引。这个列表将整个数组分割成多个区间,每个区间由一个进程处理。
# 假设 CPU_COUNT 为 4,ranges 为 [[1, 2], [3, 4], [5, 5]],数组 a 为 [8, 9, 12, 8, 0],则:
# 第一个进程处理范围 [1, 2],即数组的前两个元素 [8, 9],找到最大值 9。
# 第二个进程处理范围 [3, 4],即数组的第三和第四个元素 [12, 8],找到最大值 12。
# 第三个进程处理范围 [5, 5],即数组的最后一个元素 [0],找到最大值 0。
# 最终 results 列表为 [9, 12, 0],主进程对 results 列表取最大值,结果为 12。
print(max(results))
#********** End *********#
代码解析
-
导入必要模块:
math
,cpu_count
, 和Pool
来自multiprocessing
模块。
-
读取输入:
- 使用
input()
读取输入数据并转换为整数数组。
- 使用
-
函数
howMany
:- 这个函数计算给定范围内的最大值。
- 输入是一个列表
[start, end]
,表示数组的一个子区间。
-
函数
seprateNum
:- 根据CPU数量将数组分成若干个区间,每个区间的长度尽量相等。
- 返回一个包含多个区间范围的列表。
-
主程序:
- 确定CPU核心数
CPU_COUNT
。 - 调用
seprateNum
函数将数组分段。 - 使用
Pool
创建一个进程池,并并行计算每个分段的最大值。 - 最终在主进程中计算并输出这些结果中的最大值。
- 确定CPU核心数
运行说明
-
输入:
- 第一行是整数
n
,表示数组的长度。 - 第二行是
n
个整数,表示数组中的元素。
- 第一行是整数
-
输出:
- 一个整数,表示数组中的最大值。
代码片段解析
with Pool(CPU_COUNT) as p:
results = p.map(howMany, ranges)
1. with Pool(CPU_COUNT) as p:
Pool
: 这是multiprocessing
模块中的一个类,用于创建一个工作进程池。进程池允许你并行执行任务。CPU_COUNT
: 这是传递给Pool
的参数,表示要创建的进程数量。我们使用cpu_count()
函数来获取当前系统的CPU核心数,确保我们创建的进程数与可用的CPU核心数一致,以最大化并行执行效率。with ... as p
: 这是一个上下文管理器(context manager)的用法,确保在使用完进程池后,自动关闭并清理进程池。上下文管理器的好处是,它可以自动处理资源的分配和释放,避免手动关闭进程池。
2. results = p.map(howMany, ranges)
p.map
:map
方法将一个函数应用到一个可迭代对象的每一个元素上,这里是将howMany
函数应用到ranges
列表的每一个元素上。map
方法会将任务分配给进程池中的多个进程并行执行。howMany
: 这是我们定义的函数,用于计算数组指定区间内的最大值。它接收一个区间范围作为参数,并返回该区间内的最大值。ranges
: 这是一个列表,其中每个元素都是一个区间(子数组)的起始和结束索引。这个列表将整个数组分割成多个区间,每个区间由一个进程处理。
详细执行流程
-
创建进程池:
Pool(CPU_COUNT)
创建一个包含CPU_COUNT
个进程的进程池。进程池管理这些进程并将任务分配给它们。
-
将函数应用到数据:
p.map(howMany, ranges)
会将ranges
列表中的每一个元素(即一个区间范围)传递给howMany
函数,并将每个区间的最大值计算出来。map
方法会自动将这些计算任务分配给进程池中的多个进程并行处理。
-
获取结果:
map
方法会返回一个列表results
,其中包含每个区间的最大值。这些最大值是各个进程计算出来的结果,map
方法会在所有进程完成任务后收集这些结果并返回。
-
自动关闭进程池:
with
语句块结束时,进程池p
会被自动关闭。上下文管理器确保资源被正确释放,避免资源泄漏。
示例
假设 CPU_COUNT
为 4,ranges
为 [[1, 2], [3, 4], [5, 5]]
,数组 a
为 [8, 9, 12, 8, 0]
,则:
- 第一个进程处理范围
[1, 2]
,即数组的前两个元素[8, 9]
,找到最大值9
。 - 第二个进程处理范围
[3, 4]
,即数组的第三和第四个元素[12, 8]
,找到最大值12
。 - 第三个进程处理范围
[5, 5]
,即数组的最后一个元素[0]
,找到最大值0
。
最终 results
列表为 [9, 12, 0]
,主进程对 results
列表取最大值,结果为 12
。
多进程优势
使用多进程的优势在于可以充分利用多核CPU的并行计算能力,特别是对于大数据集时,能够显著提高计算速度。通过将任务分割并行处理,各个进程同时计算部分数据的最大值,最终汇总结果,从而达到加速计算的目的。