HackPluto's Blog

python下多核,单核CPU对于并行,并发执行效率的对比

字数统计: 2.9k阅读时长: 11 min
2019/04/19 Share

这篇博客主要内容为python 中多线程以及多进程的效率对比,以及记录自己在做这个实验中遇到的一些问题以及心得

背景引入:

CPU制造商为了追求CPU效率放弃了在CPU频率上的追求(CPU频率即CPU单位时间内可以完成任务的多少),反而开始把方向转向了多核CPU上。那么如何在多核CPU上充分发挥出多核的优势就成了一个问题。

首先是简单介绍下多线程与多进程:

线程:

是程序执行流的最小单元,是系统独立调度和分配CPU(独立运行)的基本单位。线程(有时被称为轻量级进程)跟进程有些相似,不同的是,所有的线程运行在同一个进程中, 共享相同的运行环境。它们可以想像成是在主进程或“主线程”中并行运行的“迷你进程”。

进程:

是资源分配的基本单位。一个进程包括多个线程。进程(有时被称为重量级进程)是程序的一次 执行。每个进程都有自己的地址空间,内存,数据栈以及其它记录其运行轨迹的辅助数据。操作系 统管理在其上运行的所有进程,并为这些进程公平地分配时间。进程也可以通过 fork 和 spawn 操作 来完成其它的任务。不过各个进程有自己的内存空间,数据栈等,所以只能使用进程间通讯(IPC), 而不能直接共享信息。
说简单点进程就好比QQ,浏览器这些应用程序,而线程就像QQ里和不同的人的聊天窗口或者浏览器中播放的音乐,显示的网页。

区别:

1.线程与资源分配无关,它属于某一个进程,并与进程内的其他线程一起共享进程的资源。
2.每个进程都有自己一套独立的资源(数据),供其内的所有线程共享。
3.不论是大小,开销线程要更“轻量级”
4.一个进程内的线程通信比进程之间的通信更快速,有效。(因为共享变量)

测试机:


























物理机 虚拟机
操作系统 macOS Ubuntu16.04
CPU 双核 单核
内存 8G 2G

python在管理多线程使用了GIL

pythonGIL解释:

GIL是实现python解释器时引入的一个概念,像C语言一样,python 的解释器也有很多,常见的有CPython,Psyco,PyPy。 而GIL是实现CPyhon时引入的。

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. This lock is necessary mainly because CPython’s memory management is not thread-safe. (However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

GIL的出现是有历史原因的,对于并行和并发这样的多任务,就是为了提高CPU的使用效率,然而需要注意的是,一个CPU一个时间只能实现一个任务,也就是说一个CPU永远不可能并行的,但是可以借助CPU轮训制度频繁切换任务,完成多任务。
python在刚创立的时候只考虑到了单核CPU,那么考虑到多线程的数据完整性以及状态同步就需要加一把大锁,所以呢,GIL应运而生。
GIL说简单点就是一把大锁,一把有很大权力的锁,一把可以控制CPU的锁,它可以保证一个时间内只能有一个CPU在工作,确保了多线程数据完整性和状态同步,看到这个地方,大家就有疑惑了,那还要多核CPU干什么,都成单核的了。这也确实是GIL局限性的地方。从上面的英文解释中可以看出GIL可

+以保证线程的安全,刚看到这个概念我有点疑惑,难道操作系统自身的线程调度机制不可以吗,通过查找操作系统的资料我发现,操作系统还真的不可以,下面介绍几种调度算法;
1、先到先服务调度算法(FCFS)

  根据就绪队列的到达时间来服务,此时就绪队列是一个FIFO队列,先到先服务,后到的线程不能抢占前面正在服务的线程。这种算法的优点是实现简单,缺点也很明显,就是CPU进程区间变化很大时,平均等待时间会变化很大。

  2、最短作业优先调度(SJF)

  顾名思义,就是CPU进程区间最短的先执行,如果两个进程区间具有同样的长度,那么按照FCFS来调度。

  SJF可以是抢占的,也可以是不抢占的。它的平均等待时间优于FCFS。

  3、优先级调度

  其实上面的SJF算法就是一种特殊的优先级调度,只不过这里的优先级定义更加广泛一些,SJF算法的优先级是按照CPU进程区间长短来定义的,这里的优先级可以是其他的一些定义。

  优先级调度可以是抢占的,也可以是非抢占的。

  优先级调度的一个主要问题是无穷阻塞(也称为饥饿),如果一个线程的优先级很低,可能需要等待很长的时间才能到这个线程执行,甚至永远不执行,一种解决方法是老化(随着时间的增长,增加线程的优先级)

  4、轮转法调度(RR)

  轮转法调度专门是为分时系统设计的。它类似于FCFS,但是增加了抢占为了切换线程。定义一个较小的时间单元,称为时间片,通常为10-100ms。为了实现RR算法,将就绪队列保存为FIFO队列,新进程增加到就绪队列队尾,CPU调度程序从就绪队列选择第一个进程,设置定时器在一个时间片之后再中断,再分派这个进程。

  如果该进程的CPU区间小于时间片,进程本身就会释放CPU,调度程序继续处理下一个进程,如果当前进程的CPU区间比时间片长,定时器会产生CPU中断,实行上下文切换,然后将此进程放到就绪队列队尾,继续调度就绪队列第一个进程。

可以看出计算机并不知道代码的具体含义,所以如果代码只是使用数据就没有问题,如果代码要改变数据可能会导致再多线程时数据不同步状态不统一,所以GIL这把锁就可以保证线程的安全。正如上文所说,GIL也确实是一个缺陷无法充分体现出多核的优势,那么我们应该避免这个问题呢:

1.用其他解析器

之前也提到了既然GIL只是CPython的产物,那么其他解析器是不是更好呢?没错,像JPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助。然而由于用了Java/C#用于解析器实现,他们也失去了利用社区众多C语言模块有用特性的机会。所以这些解析器也因此一直都比较小众。所以这个方法并不推荐

2.使用多进程代替多线程

用multiprocessing替代Thread
multiprocessing库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,因此也不会出现进程之间的GIL争抢。当然multiprocessing也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。multiprocessing由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用share memory的方法。这个额外的实现成本使得本来就非常痛苦的多线程程序编码,变得更加痛苦了。但是这仍然是我觉得对于初学者来说最友好的方法了。

代码实现

python中关于线程的使用涉及到threading模块,进程使用涉及multiprocessing模块
都只是使用最基本的start,len方法,所以这里不多赘述。
直接上代码:
需要的第三方库

1
2
3
4
import requests
import time
import threading
from multiprocessing import Process

首先是线性执行CPU密集型函数,IO密集型函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 定义CPU密集型函数
def count(x, y):
c = 0
while c < 500000:
c += 1
x += x
y += y


#定义IO密集型函数
def write():
f = open("test.txt", "w")
for x in range(500000):
f.write("testwrite\n")
f.close()
def read():
f = open("test.txt", "r")
lines = f.readlines()
f.close()


#定义网络请求函数
head_request = {
'User-Agent':'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36'
}
url = "http://www.tieba.com"
def http_request():
try:
res = requests.get(url, headers=head_request)
html = res.text
return {"context": html}
except Exception as e:
return {"error": e}


# CPU密集操作
t = time.time()
for x in range(10):
count(1, 1)
print("cpu函数运行时间:", time.time() - t)

# IO密集操作
t = time.time()
for x in range(10):
write()
read()
print("IO函数运行时间:", time.time() - t)

在这里插入图片描述

在这里插入图片描述
接下来是多线程并发模拟CPU密集型函数`

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 定义CPU密集型函数
def count(x, y):
c = 0
while c < 500000:
c += 1
x += x
y += y
counts = []
t = time.time()
for x in range(10):
thread = threading.Thread(target=count, args=(1,1))
counts.append(thread)
thread.start()

e = counts.__len__() #这里使用了一个魔术方法,获取counts的长度
while True:
for i in counts:
if not i.is_alive():
e -= 1
if e <= 0:
break # 当所有线程执行完毕后退出
print(time.time() - t)

在这里插入图片描述

在这里插入图片描述
多进程模拟CPU密集型函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 定义CPU密集型函数
def count(x, y):
c = 0
while c < 500000:
c += 1
x += x
y += y
counts = []
t = time.time()
for x in range(10):
process = Process(target=count, args=(1,1))
counts.append(process)
process.start()
e = counts.__len__()
while True:
for i in counts:
if not i.is_alive():
e -= 1
if e <= 0:
break
print(time.time() - t)

在这里插入图片描述

在这里插入图片描述































物理机 虚拟机
线性执行CPU密集型函数 65.29 120
线性执行IO密集型函数 1.76 1.6
多线程并发模拟CPU密集型函数 65.92 132.58
多进程并行模拟CPU密集型函数 38.87 127.9

通过上面的表格进行数据对比,明显发现在多核的情况下,python的多进程要比多线程快得多。那么是为什么呢,这里就要说到python的多线程管理机制,在python3.x中,GIL使用计时器(执行时间达到阈值后,当前线程释放GIL)对线程进行管理,这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。而且在多核的情况下可能在进程调度轮训的过程中还会产生CPU的竞争会产生更加坏的效果。

#GIL即全局解释器锁
然而对于IO密集型函数或者网络请求函数,多线程就是友好地,因为可以利用函数的挂起空闲时间进行线程的转换,充分利用到了多核CPU的优势。
在单核的情况下,模拟的多进程并行的效率也是优于多线程并发的。
Liunx多线程pthread初探:https://blog.csdn.net/xuanandting/article/details/78842795

CATALOG
  1. 1. 背景引入:
  2. 2. 首先是简单介绍下多线程与多进程:
    1. 2.1. 线程:
    2. 2.2. 进程:
    3. 2.3. 区别:
    4. 2.4. 测试机:
    5. 2.5. pythonGIL解释:
      1. 2.5.1. 1.用其他解析器
      2. 2.5.2. 2.使用多进程代替多线程
  3. 3. 代码实现