Python之并发编程
一、概述
白话:一个进程里面的子任务称为线程,所以一个进程至少有一个线程;
进程:一个具有独立功能的程序,关于某个数据集合的一次运动活动;
线程:是操作系统能够进行运算调度的最小单位,被包含在进程中,是进程的实际运作单位;
多任务的几种模式如下:
1、启动多个进程,每个进程虽然只有一个线程,但多个进程可以一块执行多个任务(多进程)
2、启动一个进程,在一个进程内启动多个线程,多个线程也能一块执行多个任务(多线程)
3、启动多进程和多线程,一般情况下不使用这个,过于复杂;
二、多线程简单实现
Python的标准库提供了两个模块:_thread和threading,前者是低级模块,后者是高级模块,一般情况下使用threading即可;
线程创建的两种方式:
1、方法包装;
2、类包装;
代码实现:
# 方法包装实现
from threading import Thread
from time import sleep
# 创建一个简单的方法
def run(name):
print(f"Thread: {name} start")
sleep(3)
# 创建线程
t1 = Thread(target=run, args=('t1', ))
t2 = Thread(target=run, args=('t2', ))
# 正常运行
run("t1")
run("t2")
# 多线程运行
t1.start()
t2.start()
通过有无开启多线程模拟程序,发现多线程下是同时执行的,大大提高了速度;
# 类实现
class MyThread(Thread):
def __init__(self, name):
super().__init__()
self.name = name
# 注意这里只能写run方法
def run(self):
print(f"Thread: {self.name} start")
sleep(3)
# 创建线程
t1 = MyThread('t1')
t2 = MyThread('t2')
# 启动线程
t1.start()
t2.start()
类实现多线程注意其中的方法名只能用run;
三、主子线程和join的使用
这里有一个概念,程序执行main程序是一个主线程,如果我们不增加等待线程执行完的操作,主线程和子线程是并行执行的,也就是方法还没执行完就会继续往后执行了;
可以由以下代码看下当前主线程:
print(threading.currentThread())
在实际项目中,往往需要当前子线程执行完再继续往后执行,这里就需要加一个等待了;
# 创建线程
t1 = MyThread('t1')
t2 = MyThread('t2')
# 启动线程
t1.start()
t2.start()
# 等待子线程执行完再往下执行
t1.join()
t2.join()
注意:
等待的位置需要写在所有子线程启动以后;
拓展:
能否使用循环来创建多个线程呢?
thread_list = []
for i in range(10):
t = Thread(target=run, arfgs=(f't{i+1}', ))
t.start()
thread_list.append(t)
for t in thread_list:
t.join()
这就是用循环的方式创建了基于方法的多线程的参考代码;
四、守护线程
一般在主线程中断时,子线程是没有被中断的,这就会导致程序执行完后,子线程还在运行;
需要通过一个设置将子线程设置为守护线程;
t.setDaemon(True)
t.start()
注意设置需要在线程启动之前,也就是start之前;
五、线程锁
问题:如果运算量过大的时候,并行计算可能会出现计算错误的问题;
from threading import Thread, Lock
def func1(name):
lock.acquire() # 获取锁
global count
for i in range(1000000):
count += 1
lock.release() # 计算完释放锁
if __name__ == "__main__":
count = 0
t_list = []
lock = Lock() # 后续添加的初始化锁的对象
for i in range(10):
t = Thread(target=func1,args=(f't{i+1}', ))
t.start()
t_list.append(t)
for i in t_list:
t.join()
print(count)
上面程序由于计算量过大,运行时打印出7961520等错误答案,这就是线程的不安全性引起的;
通过加入锁的机制,可以有效避免这个问题,上面关于lock的语句就是锁的添加;
锁的原理:
首先来看一下Python多线程内部的执行关系:
说明:其中GIL是一个Python解释器(CPython)时自带的,做为一个全局锁;他有一个缺陷就是,会有一定的时间限制,如果超出这个时间限制则解除锁,这也就是为什么计算量大的时候数据安全发生问题,所以需要我们自己添加锁;
注意:
1、如果想保证同个数据安全,必须使用同一把锁;
2、如果使用锁,程序会变成串行,所以需要在合适的地方再加锁;
3、也可以使用with来使用锁;
拓展:
如果程序出现死锁怎么办?
死锁也就是需要当前锁时,已经被另一个程序使用了并且还没释放,所以程序就类似for循环一样不断等待卡死;
解决办法:将我们的锁改成逻辑锁;
from threading import RLock
将之前的Lock(锁、同步锁、互斥锁)改成RLock(递归锁),这样可以避免死锁的问题;
六、信号量
作用:信号量可以用来设置指定个数的线程,也就是每次可以有几个线程进行运行;
下面来看一个简单的程序:
from time import sleep
from threading import Thread
from threading import BoundedSemaphore
def search(num):
lock.acquire()
print(f'第{num}个人完成安检!!!')
sleep(2)
lock.release()
if __name__ == "__main__":
lock = BoundedSemaphore(3) # 设置每次只能安检三个人
for i in range(10):
t = Thread(target=search, args=(f'{i+1}', ))
t.start()
这个程序通过信号量,设置了每次只能同时执行三个线程;
七、事件
Event()可以创建一个事件管理标志,该标志默认为False,主要有以下四种返回方法可以调用:
- event.wait(timeout=None):
调用该方法的线程会被阻塞,如果设置了timeout参数,超时后会停止阻塞线程继续执行; - event.set():
将event的标志设置为True,调用wait方法的所有线程将被唤醒; - event.clear():
将event的标志设置为False,调用wait方法的所有线程将被阻塞; - event.is_set():
判断event的标志是否为True;
下面演示一个门禁系统的程序:
from time import sleep
from threading import Thread, Event
from random import randint
state = 0
def door():
global state
while True: # 一直循环
if even.is_set(): # 一开始门是开着的
print('门开着,可以通行~')
sleep(1)
else:
print('门关了~请刷卡!')
state = 0
even.wait() # 这里将该线程进行阻塞
if state > 3:
print('超过 3 秒,门自动关门')
even.clear()
state += 1
sleep(1)
def person():
global state
n = 0
while True: # 一直循环
n += 1
if even.is_set():
print('门开着:{}号进入'.format(n))
else:
even.set() # 刷卡将阻塞线程释放
state = 0
print('门关着,{}号人刷卡进门'.format(n))
sleep(randint(1, 10))
if __name__ == '__main__':
even = Event() # 设置事件这个对象
even.set() # 将事件设置成True状态
t1 = Thread(target=door)
t1.start()
t2 = Thread(target=person)
t2.start()
事件可以理解成一个控制线程阻塞条件和释放条件的变量,主要是记住上面四个接口函数;
八、队列
线程安全的概念:
多线程编程时的计算机程序代码中的一个概念,在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常执行,不会出现数据污染;
队列好处:
1、安全
2、解耦
3、提高效率
实际上Python内部已经封装了这个队列的模块 —— Queue;
其中包含了先进先出队列,后进先出队列,优先队列等数据结构,内部都实现了锁机制,能够直接在多线程中使用;
可以参考以下文章进行代码调试:
九、生产者消费者模式
本质上生产者消费者模式是通过一个容器解决二者的强耦合问题的,也就是上面提到的队列;利用线程阻塞的原理,实现一个缓冲区的功能,平衡生产者和消费者的处理能力;
代码实现:
from threading import Thread
from queue import Queue
from time import sleep
def producer():
num = 1
while True:
print(f"生产了{num}号猫猫")
mq.put(f"{num}号猫猫")
num += 1
sleep(1)
def consumer():
while True:
print("购买了{}".format(mq.get()))
sleep(2)
if __name__ == "__main__":
# 共享数据的容器
mq = Queue(maxsize=10)
# 创建生产者线程
t1 = Thread(target=producer)
# 创建消费者线程
t2 = Thread(target=consumer)
# 开始工作
t1.start()
t2.start()
实际上也就是使用了队列模拟了生产者消费者的模式,其中内部也实现了线程安全的策略;
十、进程的创建
使用Python中的multiprocessing中的Process模块;
也是有两种实现方式:方法和类实现;
下面是基于函数实现进程的代码:
from multiprocessing import Process
from time import sleep
def func(name):
print(f"{name}进程开始...")
sleep(2)
print(f"{name}进程结束...")
if __name__ == '__main__':
p1 = Process(target=func, args=('p1', ))
p2 = Process(target=func, args=('p2', ))
p1.start()
p2.start()
下面是基于类实现进程的代码:
from multiprocessing import Process
from time import sleep
class MyProcess(Process):
def __init__(self, name):
super(MyProcess, self).__init__()
self.name = name
def run(self):
print(f"{self.name}进程开始...")
sleep(2)
print(f"{self.name}进程结束...")
if __name__ == '__main__':
p1 = MyProcess('p1')
p2 = MyProcess('p2')
p1.start()
p2.start()
实际上和线程的创建基本一致,只是换了一个模块而已;
十一、进程的通信
使用进程的优点:
1、可以使用计算机多核,进行任务的并发执行,提高执行效率;
2、运行时不受其他进程影响,创建方便;
3、由于进程间是独立的,数据安全性高;
使用进程的缺点:
进程的创建和销毁消耗的系统资源较多;
进程间的通信主要有以下两种方法:
1、使用multiprocessing模块下的Queue类,提供了进程间通信的多种方法;
2、Pipe,又称为管道,常用于实现两个进程之间的通信;
Queue实现进程通信的代码实现:
from multiprocessing import Process, Queue
import os
def fun(name, mq):
print('进程ID {}, 获取数据: {}'.format(os.getpid(), mq.get()))
if __name__ == '__main__':
print('主进程ID:{}'.format(os.getpid()))
mq = Queue()
mq.put('test')
p1 = Process(target=fun, args=('p1', mq))
p1.start()
p1.join()
可以看出两个进程之间是有实现通信的,p1进程能接收到主进程容器中的数据;
原理上是把A进程中的数据copy了一份,然后发送给了B进程;
Pipe实现进程通信的代码:
from multiprocessing import Process, Pipe
import os
def fun(name, con):
print('进程ID {}, 获取数据: {}'.format(os.getpid(), con.recv()))
con.send("Hi!")
if __name__ == '__main__':
print('主进程ID:{}'.format(os.getpid()))
con1, con2 = Pipe()
p1 = Process(target=fun, args=('p1', con1))
p1.start()
con2.send("Hello!")
p1.join()
print(con2.recv())
十二、Manager的使用
提供了进程间数据共享的方法,类似于一个容器可以在进程间共享并确保数据安全,特点也是在于支持多种数据类型;
代码实现:
from multiprocessing import Process, Manager
import os
def fun(name, m_list, m_dict):
print('进程ID {}, 获取数据: {}'.format(os.getpid(), m_list))
print('进程ID {}, 获取数据: {}'.format(os.getpid(), m_dict))
m_list.append("two")
m_dict['name'] = 'dabao'
if __name__ == '__main__':
print('主进程ID:{}'.format(os.getpid()))
with Manager() as mag:
m_list = mag.list() # 创建共享的数组对象
m_dict = mag.dict() # 创建共享的字典对象
m_list.append("one")
p1 = Process(target=fun, args=('p1', m_list, m_dict))
p1.start()
p1.join()
print(m_list)
print(m_dict)
相对于线程中的Queue类,进程的共享容器的方法更加便捷,使用的结构也是Python中的常用结构;
个人理解是将数组、字典进行了封装,加入了线程安全和通信的方法;
十三、进程池
多进程的方式存在一个问题,也就是创建和销毁进程时耗时比较久,也比较消耗内存资源;
为了解决上面这个问题,提出了进程池的概念,用于管理多进程,进程池可以提供指定数量的进程给用户使用,如果池未满则会创建新的进程,池满则会等待进程执行完空闲,类似于线程中的信号量的概念;
进程池的优点:
1、提高效率,节省开辟空间和销毁进程的时间;
2、节省内存空间;
代码实现:
from multiprocessing import Pool
import os
from time import sleep
def fun(name):
print(f"当前进程的ID:{os.getpid()},{name}")
sleep(2)
return name
def func2(args):
print(args)
if __name__ == '__main__':
pool = Pool(5)
# pool.apply(fun, args=('t1',)) # 串行添加进程任务
# 下面callback参数表示接收子进程的返回值并传递给func2这个函数
pool.apply_async(fun, args=('t1',), callback=func2) # 并行添加进程任务
pool.apply_async(fun, args=('t2',))
pool.apply_async(fun, args=('t3',))
pool.apply_async(fun, args=('t4',))
pool.apply_async(fun, args=('t5',))
pool.apply_async(fun, args=('t6',))
pool.apply_async(fun, args=('t7',))
pool.apply_async(fun, args=('t8',))
pool.close() # 关闭进程池
pool.join() # 回收进程池
实际执行可以看出,是先执行5个进程任务,再执行剩下3个;
也可以用map进行批量进程调用,代码如下:
from multiprocessing import Pool
import os
from time import sleep
def fun(name):
print(f"当前进程的ID:{os.getpid()},{name}")
sleep(2)
return name
if __name__ == '__main__':
with Pool(5) as pool:
args = pool.map(fun, ("t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8"))
for a in args:
print(a)
十四、协程
协程又称为微线程、纤程,简单来说就是一种用户态的轻量级线程;
协程的作用:
在执行函数A和函数B时,可以随时中断切换,协程是作用在一个线程中的,其执行过程很像多线程;
协程的目的:
由于多线程之前切换是需要时间的,而在单线程控制多个任务时,一旦出现一个任务遇到IO阻塞时,可以切换去执行另一个任务,从而保证该线程处于最佳的执行效率,这样的方式也就是协程;需要明确一点,线程是由CPU控制的,而协程是由程序自身控制的;
协程的标准:
必须在只有一个单线程中实现并发:
- 修改共享数据不需要加锁(因为是在一个线程中)
- 用户程序需要自己保存多个控制流的上下文栈
- 一个协程遇到IO操作后自动切换到其他协程
这个图很好的表示了进程、线程、协程之间的关系;
协程的优点:
1、自身带有上下文和栈,无需线程上下文切换的开销,属于程序级别的切换,更加轻量级;
2、无需锁操作,内部数据是共享的;
3、单线程内就可实现并发的效果,最大程度的利用CPU,成本低
(一个CPU支持上万协程都不是问题,更适合用于高并发)
协程的缺点:
1、无法利用多核资源,因为协程本质是一个单线程,需要配合进程才能运行在多CPU上;
2、进行阻塞操作时会阻塞整个程序;
协程的代码实现:
1、使用yield关键字,yield可以保存状态,构造生成器的方式,可以实现线程中任务的切换;
def func1():
for i in range(11):
print(f"A端第{i}次打印...")
yield
def func2():
g = func1()
next(g)
for k in range(10):
print(f"B端第{k}次打印...")
next(g)
if __name__ == '__main__':
func2()
使用yield可以实现线程内任务切换,但实际上并不能提升效率,实际上协程需要使用别的方法;
2、使用greenlet实现协程
使用yied生成器实现协程的方式比较麻烦,使用greenlet模块更加方便;
from greenlet import greenlet
def playA(name):
print(f'{name}: 我要干饭!!')
g2.switch('主子')
print(f'{name}: 我要出去玩!!')
g2.switch()
def playB(name):
print(f'{name}: 天天就知道吃!!')
g1.switch()
print(f'{name}: 天天就知道玩!!')
if __name__ == '__main__':
g1 = greenlet(playA)
g2 = greenlet(playB)
g1.switch('大宝')
缺点:实际上还是需要人为的在程序中进行切换;
3、使用Gevent模块实现协程
优点在于不需要人为去定义切换位置,会自动识别IO部分并进行切换;
from gevent import monkey
monkey.patch_all() #必须放到被打补丁者的前面,如 time,socket 模块之前
import gevent
from time import sleep
def playA(name):
print(f'{name}: 我要干饭!!')
sleep(2)
print(f'{name}: 我要出去玩!!')
def playB(name):
print(f'{name}: 天天就知道吃!!')
sleep(2)
print(f'{name}: 天天就知道玩!!')
if __name__ == '__main__':
# 创建协程
g1 = gevent.spawn(playA, '大宝')
g2 = gevent.spawn(playB, '主子')
# 启动协程
g1.join()
g2.join()
4、async实现协程,这个模块是使用事件循环驱动实现并发;
import asyncio
async def func1():
for i in range(5):
print("Python 真好玩!")
await asyncio.sleep(1)
async def func2():
for i in range(5):
print("使用协程能够更高效!")
await asyncio.sleep(2)
# 创建协程对象
g1 = func1()
g2 = func2()
loop = asyncio.get_event_loop() # 获取事件循环
loop.run_until_complete(asyncio.gather(g1, g2)) # 监听事件循环
loop.close() # 关闭事件
当然asyncio这个模块还有更高级的用法,但目前还没涉及到,就先不进行拓展;
十五、总结
1、串行、并行、并发的区别:
并行:指的是任务数小于等于CPU核数,即任务真的是一起执行的;
并发:指的是任务数大于CPU核数,通过操作系统的各种任务调度算法,实现多个任务"一起"执行,但实际上有些任务不在执行,只是由于切换任务速度快,看起来像一起执行;
2、进程、线程和协程的区别
- 线程是程序执行的最小单位,而进程是操作系统分配资源的最小单位;
- 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线;
- 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间和资源;
- 线程的上下文切换要比进程上下文切换快得多;
3、进程、线程和协程的特点
进程:拥有自己独立的堆和栈,既不共享堆也不共享栈,进程由操作系统调度,进程切换需要资源很大,效率很低;
线程:拥有自己独立的栈和共享的堆,标准线程由操作系统调度,线程切换需要的资源一般,效率一般;
协程:拥有自己独立的栈和共享的堆,协程由程序员在代码中控制调度,协程切换需要资源少,效率高;
多进程、多线程根据CPU核数的不同是可能实现并行的,协程是在单个线程中,所以是并发的;
End!