并发编程
多进程
进程与程序
进程
进程是实现并发的一种方式
程序:是程序猿将自己的思维逻辑按照某种编程语言规范编写下来的一堆字符串,最终形成一堆文件
多进程的实现原理-多道技术
-
在第一代电子计算机出现时 没有操作系统 也没有程序
-
第二代计算机 批处理系统 一次将一批程序 程序写在打孔卡片上
-
同一时间只有一个程序在计算机中
-
提高了计算机的利用率 但是降低了程序的调试效率
-
-
第三代 集成电路与多道技术
多道技术的实现是为了解决多个程序竞争或者说共享同一个资源(比如cpu)的有序调度问题,解决方式即多路复用,多路复用分为时间上的复用和空间上的复用。
空间复用:同一时间在内存中存放多个应用程序

空间上的复用最大的问题是:多个进程之间内存要相互独立,并且是物理层面的隔离(程序是不可能修改的),以保证安全性
时间复用:同一时间内只有一个应用程序被执行的问题
-
切换
-
当一个程序a在执行过程中遇到IO操作(IO操作通常都是非常慢的),操作系统就回切换到另一个程序执行,感觉好像是多个应用程序都在执行(并发)
-
当一个程序a执行时间过长,也会强制切换到其他的应用程序,以此保证多个程序都在执行
-
如果出现了一个优先级更高的任务,也会切换
-
当操作系统要从一个进程切换至另一个进程时,必须保存当前进程的状态,以便下次切换回来的时候继续执行
多道技术的好处
-
同一时间可以有多个应用程序在执行,在IO比较多时,极大的提高了效率
多道技术的弊端
-
如果所有应用程序都没有IO操作,反而会降低效率
总结:应用程序的执行效率取决于IO操作,IO操作越多则效率越低
提高效率的方式
-
降低IO
-
避免被操作系统切换
进程的层次结构
无论UNIX还是windows,进程只有一个父进程,不同的是:
-
在UNIX中所有的进程,都是以init进程为根,组成树形结构。父子进程共同组成一个进程组,这样,当从键盘发出一个信号时,该信号被送给当前与键盘相关的进程组中的所有成员。
-
在windows中,没有进程层次的概念,所有的进程都是地位相同的,唯一类似于进程层次的暗示,是在创建进程时,父进程得到一个特别的令牌(称为句柄),该句柄可以用来控制子进程,但是父进程有权把该句柄传给其他子进程,这样就没有层次了。
py文件要运行,必须借助python解释器,所以启动的进程是python.exe
同一个程序可以多次运行产生多个程序
每个进程都会随机分配一个PID
一个进程a开启了另一个进程b,b就是a的子进程
import os
os.getpid()# 获取自己的pid
os.getppid()# 获取父进程的pid
linux中进程之间存在关联关系:子进程和父进程是可以有只读的共享内存区的。但是对于windows系统来说,会重新加载程序代码。
在linux中进程以树状形式存在
python中开启子进程的两种方式
-
直接实例化Process类 通过target参数来指定要执行的任务(一个函数)
-
可以继承Process类,覆盖run方法 将要执行的任务放到run方法中
注意
-
在windows下 开启子进程必须放到
__main__下面,因为windows在开启子进程时会重新加载所有的代码造成递归创建进程 -
第二种方式中,必须将要执行的代码放到run方法中,子进程只会执行run方法其他的一概不管
-
start仅仅是给操作系统发送消息,而操作系统创建进程是要花费时间的,所以会有两种情况发送
# 方式一
from multiprocessing import Process
import os
def task():
print('我的pid:%s,父进程pid为:%s'%(os.getpid(),os.getpid()))
if __name__ == '__main__':
p = Process(target=task)
p.start()
print('我是主进程,我的pid是:%s'%os.getpid())
'''
windows 开启子进程与Linux开启子进程方式不同
为什么开子进程?
是因为有一段代码要交给他
所以他一定要读取你的数据,才能帮你处理
Linux会直接将主进程的内存完整copy到自己的空间中
windows导入你的程序代码,执行一遍生成一摸一样的数据,那就意味着如果子进程读到了创建子进程的代码,他也会创建子进程
结论:在windows中,创建子进程的代码必须放判断下面
'''
#方式二
from multiprocessing import Process
import os
class MyProcess(Process):
def __init__(self,url,name):
super().__init__()
self.url = url
self.name = name
def run(self):
print('开始下载:%s'%self.url)
print(os.getpid())
if __name__ == '__main__':
p = MyProcess('www.bilibili.com','B站')
p.start()
进程间内存相互隔离
尽可能多的执行代码,尽可能少的执行IO操作
buffer 缓冲区原理就是把原本需要多次执行IO操作变成一次,少次
join函数
调用start函数后的操作就由操作系统来玩了,至于何时开启进程,进程何时执行,何时结束都与应用程序无关,所以当前进程会继续往下执行,join函数就可以是父进程等待子进程结束后继续执行
from multiprocessing import Pcocess
imort os
def task():
print('我的pid:%s,父进程pid为 %s'%(os.getpid(),os.getpid)
if __name__ = '__main__':
p = Process(target = task)
p.join()# 会使主进程等待子进程执行完毕后才会继续执行,内部修改p的优先级比主进程高
p.start()
print('我是主进程,我的pid是:%s'%os.getpid())
Process对象常用属性
from multiprocessing import Process
def task():
pass
id __name__='__main__':
Process(target = ,args = task,name = '这是造着玩')
print(p.name)#在创建时可以给进程取名字
print(p.exitcoge)#获取进程的退出码,只有进程完全结束后才能获取到
print(p.pid)# 获取进程pid
p.terminate()# 终止进程,与start相同的是都是给操作系统发送信号,发完继续执行,所以不会立刻就终止成功
print(p.is_alive())# 获取进程的存活状态
并发 并行 串行 阻塞
并发:多个事件同时发生了
并行:多个事件同时进行着
串行: 程序按照固定顺序执行 如果上一行没有执行完毕 下一行不可能会执行。如果遇到了一个有很多计算操作的任务 也可能会卡主,但是CPU仍然在执行应用程序
阻塞与非阻塞指的是程序的状态
-
阻塞状态是因为程序遇到了IO操作,或是sleep,导致后续的代码不能被CPU执行
-
非阻塞与之相反,表示程序正在正常被CPU执行
补充:进程有三种状态

孤儿进程与僵尸进程
什么是孤儿进程
孤儿进程指的是开启子进程后,父进程先于子进程终止了,那这个子进程就称之为孤儿进程
孤儿进程是无害的,有其存在的必要性,在父进程结束后,其子进程会被操作系统接管。
什么是僵尸进程
僵尸进程指的是,当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程。该情况仅在linux下出现。windows中进程间完全是独立的没有任何关联。
如果父进程先退出 ,子进程被操作系统接管,子进程退出后操作系统会回收其占用的相关资源!
僵尸进程的危害
由于子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程 到底什么时候结束. 那么会不会因为父进程太忙来不及wait子进程,或者说不知道 子进程什么时候结束,而丢失子进程结束时的状态信息呢? 不会。因为UNⅨ提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就必然可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid来取时才释放. 但这样就导致了问题,如果进程不调用wait / waitpid的话,那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生[僵死进程],将因为没有可用的进程号而导致系统不能产生新的进程. 此为僵尸进程的危害,应当避免。
python已经自动帮你处理僵尸进程
守护进程
也是一个进程,可以守护着另一个进程 守护进程会在主进程 代码执行完毕后立即结束
使用场景
form multiprocessing import Process
def task():
print('子进程running.....')
if __name__=__main__:
p = Process(tagert = task)
# 将子进程设置为主进程的守护进程,必须放在开启进程之前设置
p.daemon = True
p.strat()
进程安全问题
当多个进程要同时操作同一个资源时,就有可能出现问题:
-
同时运行多个进程,同时访问(读写)了一个共享的资源,可能会造成数据错乱
-
同时读数据不会出现问题,同时写数据才会出问题,一个读一个写也会出问题
最终解决方案:
之所以出现问题的原因在于:多个进程并发的在执行代码,答案就是加锁来保证 一个进程在访问数据的时候其他进程都不能访问,加锁就能保证安全,但是把并发变成了串行
-
使用Lock来实例化产生一把锁
-
但是要保证每个进程访问的都是同一把锁
-
访问完毕后一定要解锁
-
注意:不能多次执行acquire,一次acqurie,对应一次release
-
acquire是一个阻塞函数,会一直等到锁被释放(release调用),才会继续执行
加锁之后并发又变成串行,不过与join不同的是没有规定顺序,谁先抢到谁先执行
join也能够使进程串行执行 但是join会使得执行顺序被固定死 很明显是不合理,锁可以仅仅把部分代码串行 其他代码还是并发执行
问题:一旦加锁,效率变低,不加锁数据要错乱
from multiprocessing import Process,Lock
import time,random
def task1(lock):
lock.acquire()
print('hello my name is egon')
time.sleep(random.randint(1,3))
print('egon age is 18')
time.sleep(random.randint(1,3))
print('egon sex is unknow')
lock.release()
def task2(lock):
lock.acquire()
print('hello my name is 常委')
time.sleep(random.randint(1,3))
print('常委 age is 15')
time.sleep(random.randint(1,3))
print('常委 sex is woman')
lock.release()
def task3(lock):
lock.acquire()
print('hello my name is lxx')
time.sleep(random.randint(1,3))
print('lxx age is 40')
time.sleep(random.randint(1,3))
print('lxx sex is woman')
lock.release()
if __name__ == '__main__':
lock = Lock()
p1 = Process(target = task1,args = (lock,))
p2 = Process(target = task2,args = (lock,))
p3 = Process(target = task3,args = (lock,))
p1.start()
p2.start()
p3.start()
p1.join()
p2.join()
p3.join()
抢票程序
服务器要存储票数
客户端要查看票数
如果票数大于0就可以购买
在使用锁的时候无可避免的会降低效率,需要找到一个最合适的地方加上锁,你锁住的代码越少效率越高
join和锁?
join是让整个进程中的代码全部串行,而锁可以部分代码串行
粒度(被锁住的代码量)越小,效率越高
互斥锁
互相排斥对方的锁
IPC
进程间通讯
-
进程与进程之间是物理隔离,无法直接通讯
-
使用一个共享文件,在硬盘创建一个文件,不同进程间之间共享这个文件
-
优点:交换的数据量几乎没有限制
-
缺点:速度慢
-
-
系统开辟一块共享内存,以供进程间交换数据
-
优点:速度快
-
缺点:交换的数据量不能太大
-
Manager类:共享内存管理器
-
from multiprocessing import Process,Manager
def task(dic):
dic['name'] = '刘阿猫'
if __name == '__main__':
#共享内存管理
m = Manager()
#在共享内存区域创建一个字典(可以是列表 字典 array数组)
dic = m.dic({'name':'陈阿狗'})
# 将处于共享内存区域的字典传给子进程
p = Process(target = task,args= (dic,))
p.start()
p.join()
print(dic['name'])
-
-
进程Queue
-
-
管道
-
优点:封装了文件的打开,关闭等操作
-
缺点:速度慢,并且是单向的,编程的复杂度较高
-
-
socket
-
优点:不仅可以用于远程计算机中的进程通讯,还可以用于与本地进程的通讯
-
基于内存的速度快
-
Queue 队列
队列:是一种容器
特点:
-
先进先出
-
支持进程间共享
-
内部自带锁机制,处理安全问题
-
put,get 默认都是阻塞
from multiprocessing import Queue
q = Queue(2)
q.put('hehe')
q.put('haha')
q.put('3')#会阻塞直到有空位置为止
q.put('4',block = False,timeout = 5)#time等待超时,只在block = True时有效
print(q.get())
print(q.get(block = False,timeout = 5))
堆栈
特点:先进后出
from multiprocessing import Process,Queue
def task(q):
num = q.get()
num -= 1
q.put(num)
if __name == '__main__':
q =Queue
q.put(3)
# 将处于共享内存区域的字典传给子进程
p1 = Process(target = task,args= (q))
p2 = Process(target = task,args= (q))
p3 = Process(target = task,args= (q))
p1.start()
p2.start()
p3.start()
p1.join()
p2.join()
p3.join()
print(dic['name'])
生产者消费者模型
模型:即解决某个问题的套路
问题(生产者与消费者能力不平衡):
-
生产者负责产生数据
-
消费者处理数据
-
当消费者与生产者的能力不匹配时,必然要等待另一方,这样效率就变低了
解决办法:
-
将原本由同一个进程完成的两个(生产和消费)拆分为两个不同的角色(进程)来完成
-
由于进程间内存相互隔离,所以需要为两个角色之间提供一个共享的数据容器
-
生产将生产完成的数据放入容器中
-
消费者从容器中取出数据来处理
生产者消费者模型的优点:
-
平衡了生产者和消费者之间的能力差异
JoinableQueue
可以等待队列中的数据全部处理完毕
处理一个就调用一次task_done 队列会自动计算,存储的数据个数与task_done的调用次数相同,意味着处理完毕
使用场景:在生产者消费者模型中,当生产者不知道生产者会产生多少数据是,就可以使用这个队列
from multiprocessing import JoinableQueue
q = JoinableQueue()
q.put(1)
q.put(2)
q.get()
q.task_done()
q.task_done()# 告诉容器已经处理完了一个数据
q.join()# 也是一个阻塞函数,一直到队列中的数据被处理完毕(task_done的调用次数等于队列中的数据数量)
print(q.get())
多线程
什么是线程
线程
一条流水线,整个执行过程的总称,也是一个抽象概念
线程是CPU的最小执行单位,是具体负责执行代码的
进程的一个资源单位,其中包括了该程序运行所需的所有资源
线程与进程的区别:
-
进程类似一个车间 而线程是车间中的一条流水线 ,是一个资源单位
-
一个进程中至少包含一个线程,称之为主线程,由系统自动创建的
-
一个进程中可以有多个线程
-
同一个进程中线程之间数据是共享的
-
创建线程的开销远小于创建进程
-
线程之间是平等的 没有子父级关系
-
创建线程的代码可以写在任何位置
-
无论开启了多少子线程PID是不会变的
如何使用:使用的方式与进程一样
创建线程的代码可以写在任何位置
为什么使用线程
提高程序的执行效率,例如一条生产线遇到IO阻塞 可以切换到其他线程来执行任务 避免CPU切换到其他进程
线程的使用方式
-
实例化Thread类
from threading import Thread
def task():
print('running.....')
if __name__ == '__main__':
#开启线程速度比开启进程快很多
t = Thread()
t.start
-
继承Thread类,覆盖run方法
class MyThread(Thread):
def run(self):
print('running....')
MyThread().start()
主线程与子线程的区别
-
线程之间是没有父子之分,是平等的
-
主线程是由操作系统自动开启的,而子线是由程序主动开启
-
即时主线程的代码执行完毕,也不会结束进程,会等待所有线程执行完毕,进程才结束
from threading import Thread
import time
def task():
print('子线程running.....')
time.sleep(3)
print('子线程over.....')
t= Thread(target = task)
t.start()
print('main over')
线程之间数据共享
from threading import Thread
import time
#与进程的区别之一,数据是共享的
a = 10
def task():
global a
time.sleep(0.5)
a = -1
print('子线程over....')
t= Thread(target = task)
t.start()
t.join()#阻塞函数,会一直等到子线程运行结束
print(a)
print('main over')
#区别2 创建进程与创建线程的开销
from threading import Thread,Process
def task():
pass
if __name__ == '__main__':
strat = time.time()
ps = []
for i in range(100):
p = Process(target = task)
p.strat()
ps.append(p)
for p in ps:
p.join()
print(time.time() - start)
线程的安全
线程安全也是通过锁来保证,锁的用法与进程中一摸一样
from threading import Thread,Lock
import sleep
num = 10
lock = Lock()
def task():
global num
lock = acquire()
a = num
time.sleep(0.1)
a = a-1
lock.release()
ts = []
for i in range(10):
t = Thread(target = task)
t.start()
ts.append(t)
for t in ts:
t.join()
print(num)
守护线程
设置守护线程的语法与进程相同,相同的是也必须放在线程开启前设置,否则抛出异常。
守护线程会在主线程结束后立即结束,即使任务没有完成
主线程会等待所有子线全部完成后结束
守护线程会在所有非守护线程结束后结束
from threading import Thread
def task():
print('%s正在运行....' % current_thread().name)
time.sleep(3)
print('%sover....' % current_thread().name)
t = Thread(target = task)
t.daemon = True#t.setdaemon()
t.start()
print('over')
Tread类的常用属性
# threading模块包含的常用方法
import threading
print(threading.current_thread().name) #获取当前线程对象
print(threading.active_count()) # 获取目前活跃的线程数量
print(threading.enumerate()) # 获取所有线程对象
from threading import Thread
t = Thread(name="aaa")
# t.join() # 主线程等待子线程执行完毕
print(t.name) # 线程名称
print(t.is_alive()) # 是否存活
print(t.isDaemon()) # 是否为守护线程
线程锁
互斥锁
多线程的最主要特征之一是:同一进程中所有线程数据共享
一旦共享必然出现竞争问题。
a = 10
#lock = Lock()
def task():
global a
#lock.acquire()
b = a - 1
time.sleep(0.1)
a = b
#lock.release()
for i in range(10):
t = Thread(target=task)
t.start()
for t in threading.enumerate():
if t != threading.current_thread():
t.join()
print(a)
# 输出 9
当多个线程要并发修改同一资源时,也需要加互斥锁来保证数据安全。
同样的一旦加锁,就意味着串行,效率必然降低。
死锁
当你今后在开发一些高并发程序时,很可能出现线程/进程安全问题,解决方案只有加锁,但是在使用锁时,很有可能会出现死锁问题
出现死锁的两种情况:
-
对同一把锁调用了多次,导致死锁问题(最low的死锁问题),应该避免在代码中出现这种写法
from threading import Thread,Lock
lock = Lock()
lock.acquire()
lock.acquire()
print('over')
2.有多把锁,一个线程抢一把锁,要完成任务必须同时抢到所有的锁,这将导致死锁问题
from threading import Thread,Lock
import time
#一个盘子和一双筷子
lock1 = Lock()
lock2 = Lock()
def task1(name):
lock1.acquire()
print('%s抢到了盘子' % name)
lock2.acquire()
print('%s抢到了筷子' % name)
print('吃饭了...')
lock1.release()
lock2.release()
def task2(name):
lock2.acquire()
print('%s抢到了筷子' % name)
lock1.acquire()
print('%s抢到了盘子' % name)
print('吃饭了...')
lock1.release()
lock2.release()
t1 = Thread(target = task1)
如何避免:
-
对同一把锁执行了多次acquire
-
有不止一把锁,不同线程或进程分别拿到了不同的锁不放
其中第一种情况我们可以通过可重入锁来解决
可重入锁
Rlock 同一个线程可以多次执行acquire,释放锁时,有几次acquire就要release几次。
但是本质上同一个线程多次执行acquire时没有任何意义的,其他线程必须等到RLock全部release之后才能访问共享资源。
所以Rlock仅仅是帮你解决了代码逻辑上的错误导致的死锁,并不能解决多个锁造成的死锁问题
# 同一把RLock 多次acquire
#l1 = RLock()
#l2 = l1
# 不同的RLock 依然会锁死
#l1 = RLock()
#l2 = RLock()
def task():
l1.acquire()
print(threading.current_thread().name,"拿到了筷子")
time.sleep(0.1)
l2.acquire()
print(threading.current_thread().name, "拿到了盘子")
print("吃饭")
l1.release()
l2.release()
def task2():
l2.acquire()
print(threading.current_thread().name, "拿到了盘子")
l1.acquire()
print(threading.current_thread().name,"拿到了筷子")
print("吃饭")
l2.release()
l1.release()
t1 = Thread(target=task)
t1.start()
t2 = Thread(target=task2)
t2.start()
#只能防止一个问题,就是同一线程多次执行acquire
信号量
Semaphore
信号量也是一种锁,其特殊之处在于可以让一个资源同时被多个线程共享,并控制最大的并发访问线程数量。
如果把Lock比喻为家用洗手间,同一时间只能一个人使用。
那信号量就可以看做公共卫生间,同一时间可以有多个人同时使用。
from threading import Thread,Semaphore,current_thread
import time
s = Semaphore(3)
def task():
s.acquire()
print("%s running........" % current_thread())
time.sleep(1)
s.release()
for i inrange(20):
r = Thread(target = task)
t.start
GIL
什么是GIL
是一个全局解释器锁,是一个互斥锁
官方解释:
'''
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.)
'''
释义:
在CPython中,这个全局解释器锁,也称为GIL,是一个互斥锁,防止多个线程在同一时间执行Python字节码,这个锁是非常重要的,因为CPython的内存管理非线程安全的,很多其他的特性依赖于GIL,所以即使它影响了程序效率也无法将其直接去除
总结:
在CPython中,GIL会把线程的并行变成串行,导致效率降低
需要知道的是:解释器并不只有CPython,还有PyPy,JPython等等。GIL也仅存在与CPython中,这并不是Python这门语言的问题,而是CPython解释器的问题!
GIL带来的问题
首先必须明确执行一个py文件,分为三个步骤
-
从硬盘加载Python解释器到内存
-
从硬盘加载py文件到内存
-
解释器解析py文件内容,交给CPU执行
其次需要明确的是每当执行一个py文件,就会立即启动一个python解释器,
当执行test.py时其内存结构如下:
GIL,叫做全局解释器锁,加到了解释器上,并且是一把互斥锁,那么这把锁对应用程序到底有什么影响?
这就需要知道解释器的作用,以及解释器与应用程序代码之间的关系
py文件中的内容本质都是字符串,只有在被解释器解释时,才具备语法意义,解释器会将py代码翻译为当前系统支持的指令交给系统执行。
当进程中仅存在一条线程时,GIL锁的存在没有不会有任何影响,但是如果进程中有多个线程时,GIL锁就开始发挥作用了。如下图:

开启子线程时,给子线程指定了一个target表示该子线程要处理的任务即要执行的代码。代码要执行则必须交由解释器,即多个线程之间就需要共享解释器,为了避免共享带来的数据竞争问题,于是就给解释器加上了互斥锁!
由于互斥锁的特性,程序串行,保证数据安全,降低执行效率,GIL将使得程序整体效率降低!
为什么需要GIL
GIL与GC的孽缘
在使用Python中进行编程时,程序员无需参与内存的管理工作,这是因为Python有自带的内存管理机制,简称GC。那么GC与GIL有什么关联?
要搞清楚这个问题,需先了解GC的工作原理,Python中内存管理使用的是引用计数,每个数会被加上一个整型的计数器,表示这个数据被引用的次数,当这个整数变为0时则表示该数据已经没有人使用,成了垃圾数据。
当内存占用达到某个阈值时,GC会将其他线程挂起,然后执行垃圾清理操作,垃圾清理也是一串代码,也就需要一条线程来执行。
示例代码:
from threading import Thread
def task():
a = 10
print(a)
# 开启三个子线程执行task函数
Thread(target=task).start()
Thread(target=task).start()
Thread(target=task).start()
上述代码内存结构如下:

通过上图可以看出,GC与其他线程都在竞争解释器的执行权,而CPU何时切换,以及切换到哪个线程都是无法预支的,这样一来就造成了竞争问题,假设线程1正在定义变量a=10,而定义变量第一步会先到到内存中申请空间把10存进去,第二步将10的内存地址与变量名a进行绑定,如果在执行完第一步后,CPU切换到了GC线程,GC线程发现10的地址引用计数为0则将其当成垃圾进行了清理,等CPU再次切换到线程1时,刚刚保存的数据10已经被清理掉了,导致无法正常定义变量。
当然其他一些涉及到内存的操作同样可能产生问题问题,为了避免GC与其他线程竞争解释器带来的问题,CPython简单粗暴的给解释器加了互斥锁,如下图所示:

有了GIL后,多个线程将不可能在同一时间使用解释器,从而保证了解释器的数据安全。
总结
-
为什么需要GIL:因为一个python.exe进程中只有一个解释器,如果这个进程开启了多了线程,都需要执行代码,多线程之间要竞争解释器,一旦竞争就有可能出现问题
-
带来的问题:同一时间只有一个线程可以访问解释器
-
好处:保证了多线程的数据安全
-
Tread-safe 线程安全 多个线程同时访问也不会出问题
not Tread-safe 非线程安全 多个线程同时访问可能会出现问题(加锁)
默认情况下一个进程只有一个线程,是不会出现问题,但是不要忘记还GC线程
一旦出现多个线程就可能出现问题,所以当初就简单粗暴的加上GIL锁
GIL的加锁与解锁时机
加锁的时机:在调用解释器时立即加锁
解锁时机:
-
当前线程遇到了IO时释放
-
当前线程执行时间超过设定值时释放(py3中把原本按照字节码执行次数来切换(py2中) 变成了按照执行时间)
关于GIL的性能讨论
GIL的优点:
-
保证了CPython中的内存管理是线程安全的
GIL的缺点:
-
互斥锁的特性使得多线程无法并行
应用程序分为两种:
-
IO密集型,IO操作较多,纯计算较少
-
计算密集型,计算操作较多,IO较少
应用场景:
-
TCP程序,IO密集型,应该采用多线程
-
纯计算,例如人脸识别,语音识别,采取多进程
另外:之所以广泛采用CPython解释器,就是因为大量的应用程序都是IO密集型的,还有另一个很重要的原因是CPython可以无缝对接各种C语言实现的库,这对于一些数学计算相关的应用程序而言非常的happy,直接就能使用各种现成的算法
自定义的线程锁与GIL的区别
GIL保护的是解释器级别的数据安全,比如对象的引用计数,垃圾分代数据等等,具体参考垃圾回收机制详解。
对于程序中自己定义的数据则没有任何的保护效果,这一点在没有介绍GIL前我们就已经知道了,所以当程序中出现了共享自定义的数据时就要自己加锁
进程池与线程池
什么是进程/线程池?
池表示一个容器,本质上就是一个存储进程或线程的列表
池子中存储线程还是进程?
如果是IO密集型任务使用线程池,如果是计算密集任务则使用进程池
为什么需要进程/线程池?
在很多情况下需要控制进程或线程的数量在一个合理的范围,例如TCP程序中,一个客户端对应一个线程,虽然线程的开销小,但肯定不能无限的开,否则系统资源迟早被耗尽,解决的办法就是控制线程的数量。
线程/进程池不仅帮我们控制线程/进程的数量,还帮我们完成了线程/进程的创建,销毁,以及任务的分配
进程池的使用:
from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
import time,os
# 创建进程池,指定最大进程数为3,此时不会创建进程,不指定数量时,默认为CPU和核数
pool = ProcessPoolExecutor(3)
def task():
time.sleep(1)
print(os.getpid(),"working..")
if __name__ == '__main__':
for i in range(10):
pool.submit(task) # 提交任务时立即创建进程
# 任务执行完成后也不会立即销毁进程
time.sleep(2)
for i in range(10):
pool.submit(task) #再有新任务是 直接使用之前已经创建好的进程来执行
线程池的使用:
from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
from threading import current_thread,active_count
import time,os
# 创建进程池,指定最大线程数为3,此时不会创建线程,不指定数量时,默认为CPU和核数*5
pool = ThreadPoolExecutor(3)
print(active_count()) # 只有一个主线
def task():
time.sleep(1)
print(current_thread().name,"working..")
if __name__ == '__main__':
for i in range(10):
pool.submit(task) # 第一次提交任务时立即创建线程
# 任务执行完成后也不会立即销毁
time.sleep(2)
for i in range(10):
pool.submit(task) #再有新任务时 直接使用之前已经创建好的线程来执行
案例:TCP中的应用
首先要明确,TCP是IO密集型,应该使用线程池
同步异步-阻塞非阻塞
阻塞非阻塞
阻塞:当程序执行过程中遇到了IO操作,在执行IO操作时,程序无法继续执行其他代码,称为阻塞!
非阻塞:程序在正常运行没有遇到IO操作,或者通过某种方式使程序即时遇到了也不会停在原地,还可以执行其他操作,以提高CPU的占用率
指的是程序的执行的状态
同步-异步
指的是提交任务的方式
同步调用:发起任务后必须在原地等待任务执行完成,才能继续执行
异步调用:发起任务后必须不用等待任务执行,可以立即开启执行其他操作
同步会有等待的效果但是这和阻塞是完全不同的,阻塞时程序会被剥夺CPU执行权,而同步调用则不会!
异步效率高于同步
-
发起异步任务的方式,就是线程和进程
-
场景:当你的任务是不需要立即获取结果,并且还有其他的任务需要处理,那就发起异步任务
同步和阻塞时完全不同:
-
阻塞一定是cpu已经切走了
-
同步虽然也会卡住,但是cpu没有切走,还在你的进程中
程序中的异步调用并获取结果方式1:
from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time
pool = ThreadPoolExecutor(3)
def task(i):
time.sleep(0.01)
print(current_thread().name,"working..")
return i ** i
if __name__ == '__main__':
objs = []
for i in range(3):
res_obj = pool.submit(task,i) # 异步方式提交任务# 会返回一个对象用于表示任务结果
objs.append(res_obj)
# 该函数默认是阻塞的 会等待池子中所有任务执行结束后执行,关闭之后不能提交新任务
pool.shutdown(wait=True)
# 从结果对象中取出执行结果
for res_obj in objs:
print(res_obj.result())
print("over")
程序中的异步调用并获取结果方式2:
from concurrent.futures import ThreadPoolExecutor
from threading import current_thread
import time
pool = ThreadPoolExecutor(3)
def task(i):
time.sleep(0.01)
print(current_thread().name,"working..")
return i ** i
if __name__ == '__main__':
for i in range(3):
res_obj = pool.submit(task,i) # 会返回一个对象用于表示任务结果
print(res_obj.result()) #result,获取任务的返回值,即task任务的返回值,会把异步变为同步,会把并行编程串行,同步的一旦调用就必须等待 任务执行完成拿到结果
print("over")
直接使用进程池和线程池发起异步任务
from multiprocessing import Process
from threading import Thread
import time
def task():
time.sleep(2)
print('run...')
if __main__ = '__main__':
p = Process(target = task)
p = Thread(taget = task)
p.start()
print('over')
获取异步任务结果的方式
爬虫:
-
获取到HTML文档
-
从文档中取出需要的数据
回调:其实说的是回调函数,给异步任务绑定一个函数,当任务完成时会自动调用该函数
具体使用:当你往pool中添加了一个异步任务,回返回一个表示结果的对象,有一个对象绑定方法add_done_callback,需要一个函数作为参数
注意:回调函数必须有且只有一个参数,就是对象那个本身,通过对象.result来获取结果
-
优点:不用原地等待,任务结果可以立即获取到
import requests
from concurrent.future import ThreadPoolExecutor
def get_data(url):
resp = requests.get(url)
#print(resp.content)# 返回二进制数据
print('%s正在处理%s' %(threading.current_thread().name,url))
#paeser_data(resp.text)# 方式3 直接调用处理函数,生产者和消费者强行耦合在一起
return resp.text# 直接解码
def parser_data(f):
res = f.result()
print('解析长度为:',len(data))
# 要爬取的地址列表
urls = ['网址1','网址2','网址3']
pool = ThreadPoolExecutor()
fs = []
for url import urls:
# 把并发变成串行
# f = pool.submit(get_data,url)
# data = f.result()
# parser_data(data)
f = pool.submit(get_data,url)
f.add_done_callback(parser_data)
#fs.append(f)
#必须等待全部完成
#pool.shutdown()
#for f in fs:
# data = f.result()
# parser_data(data)
线程队列
queue该模块下提供了一些常见的数据容器,但是他们仅仅是容器,每一数据共享这个特点
from queue import Queue,LifoQueue,PriorityQueue
q = Queue()
q.put(1)
q.put(2)
print(q.get())
print(q.get(timeout = 2))
# 后进先出(堆栈)
q = LifoQueue
q.put(1)
q.put(2)
print(q.get())
# 优先级队列
# 需要传入一个元组类型,第一个是优先级,第二是值
q = PriorityQueue()
q.put((1,'asd'))
q.put((2,'asd'))
事件
是一个通知信息,表示什么时间发生了什么事
用于线程间通讯
线程间本来就是数据共享的,也就是说,即使没有事件这个东西,也是没有问题的
线程之间,执行流程事完全独立的,一些时候可能需要知道另一个线程发生了什么,然后采取一些行动,这时候就可以使用事件来简化代码
事件其实就是帮你维护了一个bool值,在bool为True之前,wait函数一直阻塞,这样以来就避免了不断询问对方的状态
# 假设有两条线程,一个用于开启服务器,一个用于连接服务器
# 连接服务器一定要保证服务器已经开启成功,服务器启动需要花费一些时间
import time,random
from threading impoet Thread,Event
is_boot = False
boot = Event()
def boot_server():
global is_boot
print('正在启动服务器。。。。。')
time.sleep(random.randint(2,5))
print('服务器启动成功.....')
#is_boot = True
boot.set()
def connect_server():
#while True:
#if is_boot:
# print('连接服务器成功!')
# break
#else:
# print('服务器未启动')
print('开始尝试连接')
boot.wait()# 是一个阻塞函数,会一直等到set函数被调用
print('连接服务器成功')
t1 = Thread(target = boot_server)
t1.start()
t2 = Thread(target = connect_server)
t2.start()
协程
引子
上一节中我们知道GIL锁将导致CPython无法利用多核CPU的优势,只能使用单核并发的执行。很明显效率不高,那有什么办法能够提高效率呢?
效率要高只有一个方法就是让这个当前线程尽可能多的占用CPU时间,如何做到?
任务类型可以分为两种 IO密集型 和 计算密集型
对于计算密集型任务而言 ,无需任何操作就能一直占用CPU直到超时为止,没有任何办法能够提高计算密集任务的效率,除非把GIL锁拿掉,让多核CPU并行执行。
对于IO密集型任务任务,一旦线程遇到了IO操作CPU就会立马切换到其他线程,而至于切换到哪个线程,应用程序是无法控制的,这样就导致了效率降低。
如何能提升效率呢?想一想如果可以监测到线程的IO操作时,应用程序自发的切换到其他的计算任务,是不是就可以留住CPU?的确如此
单线程实现并发
单线程实现并发这句话乍一听好像在瞎说
首先需要明确并发的定义
并发:指的是多个任务同时发生,看起来好像是同时都在进行
并行:指的是多个任务真正的同时进行
早期的计算机只有一个CPU,既然CPU可以切换线程来实现并发,那么为何不能再线程中切换任务来并发呢?
上面的引子中提到,如果一个线程能够检测IO操作并且将其设置为非阻塞,并自动切换到其他任务就可以提高CPU的利用率,指的就是在单线程下实现并发。
如何能够实现并发
并发 = 切换任务+保存状态,只要找到一种方案,能够在两个任务之间切换执行并且保存状态,那就可以实现单线程并发
python中的生成器就具备这样一个特点,每次调用next都会回到生成器函数中执行代码,这意味着任务之间可以切换,并且是基于上一次运行的结果,这意味着生成器会自动保存执行状态!
于是乎我们可以利用生成器来实现并发执行:
def task1():
while True:
yield
print("task1 run")
def task2():
g = task1()
while True:
next(g)
print("task2 run")
task2()
并发虽然实现了,但这对效率的影响是好是坏呢?来测试一下
# 两个计算任务一个采用生成器切换并发执行 一个直接串行调用
import time
def task1():
a = 0
for i in range(10000000):
a += i
yield
def task2():
g = task1()
b = 0
for i in range(10000000):
b += 1
next(g)
s = time.time()
task2()
print("并发执行时间",time.time()-s)
# 单线程下串行执行两个计算任务 效率反而比并发高 因为并发需要切换和保存
def task1():
a = 0
for i in range(10000000):
a += i
def task2():
b = 0
for i in range(10000000):
b += 1
s = time.time()
task1()
task2()
print("串行执行时间",time.time()-s)
可以看到对于纯计算任务而言,单线程并发反而使执行效率下降了一半左右,所以这样的方案对于纯计算任务而言是没有必要的
我们暂且不考虑这样的并发对程序的好处是什么,在上述代码中,使用yield来切换是的代码结构非常混乱,如果十个任务需要切换呢,不敢想象!因此就有人专门对yield进行了封装,这便有了greenlet模块
greenlet模块实现并发
def task1(name):
print("%s task1 run1" % name)
g2.switch(name) # 切换至任务2
print("task1 run2")
g2.switch() # 切换至任务2
def task2(name):
print("%s task2 run1" % name)
g1.switch() # 切换至任务1
print("task2 run2")
g1 = greenlet.greenlet(task1)
g2 = greenlet.greenlet(task2)
g1.switch("jerry") # 为任务传参数
该模块简化了yield复杂的代码结构,实现了单线程下多任务并发,但是无论直接使用yield还是greenlet都不能检测IO操作,遇到IO时同样进入阻塞状态,所以此时的并发是没有任何意义的。
现在我们需要一种方案 即可检测IO又能够实现单线程并发,于是gevent闪亮登场
协程概述
协程:是单线程下的并发,又称微线程,纤程。英文名Coroutine。一句话说明什么是线程:协程是一种用户态的轻量级线程,即协程是由用户程序自己控制调度的。
需要强调的是:
#1. python的线程属于内核级别的,即由操作系统控制调度(如单线程遇到io或执行时间过长就会被迫交出cpu执行权限,切换其他线程运行)
#2. 单线程内开启协程,一旦遇到io,就会从应用程序级别(而非操作系统)控制切换,以此来提升效率(!!!非io操作的切换与效率无关)
对比操作系统控制线程的切换,用户在单线程内控制协程的切换
优点如下:
#1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级
#2. 单线程内就可以实现并发的效果,最大限度地利用cpu
缺点如下:
#1. 协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程来尽可能提高效率
#2. 协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程
gevent协程的使用
import gevent,sys
from gevent import monkey # 导入monkey补丁
monkey.patch_all() # 打补丁
import time
print(sys.path)
def task1():
print("task1 run")
# gevent.sleep(3)
time.sleep(3)
print("task1 over")
def task2():
print("task2 run")
# gevent.sleep(1)
time.sleep(1)
print("task2 over")
g1 = gevent.spawn(task1)
g2 = gevent.spawn(task2)
# g1.join()
gevent.joinall([g1,g2])
需要注意:
-
协程执行时要想使任务执行则必须对协程对象调用join函数
-
有多个任务时,随便调用哪一个的join都会并发的执行所有任务,但是需要注意如果一个存在io的任务没有被join该任务将无法正常执行完毕
-
monkey补丁的原理是把原始的阻塞模块替换为修改后的非阻塞模块,即偷梁换柱,来实现IO自定切换,所以打补丁的位置一定要放到导入阻塞模块之前
IO模型
IO指的是输入输出,输入输出都是一个耗时的操作,程序中一旦遇到了输入输出就会被阻塞,导致程序效率降低,IO模型也就是输入输出模型,是为了提高IO效率而出现。
IO本质上也分为不同类型,其中最典型的就是网络IO,由于网络速度比运算速度慢很多,所以大量的时间都是在等待网络IO,这也是我们要关注的重点!
copyData与waitData
网络通讯时,应用程序的数据是交由操作系统来进行发送的,同样接收数据时也是操作系统先收到消息。
为了更好的理解IO模型,需要先了解数据的接收和发送经历了哪些阶段过程。
IO模型有以下几个:
1.发送数据时 send sendto
数据从应用程序内存copy到系统缓存,后续操作由操作系统完成,只需经历copydata阶段
import socket
c = socket.socket()
c.connect(("127.0.0.1",9898))
while True:
data = input(":")
if not data:continue
c.send(data.encode("utf-8")) # 阻塞函数 速度较快 感觉不到阻塞
2.接收数据时 recv recvfrom accept
向操作系统发起读取操作后,必须要等待数据到达缓冲区,然后在从缓冲区copy到应用程序内存
所以接收数据 需要先经历waitData 再经历copyData
import socket
s = socket.socket()
s.bind(("127.0.0.1",9898))
s.listen()
while True:
c,addr = s.accept() # 阻塞
while True:
data = c.recv(1024) # 阻塞
print(data.decode("utf-8"))
IO模型分类
阻塞IO
阻塞IO指的是程序一旦发起了相关的调用后,必须在阻塞在原地,等待IO操作结束后才能继续执行。
目前所学的所有TCP程序都属于阻塞IO模型(gevent除外),默认情况下socket提供的一系列方法都是阻塞的
如:recv send accept等,
需要强调的是:无论是什么样的IO模型都必须经历waitData和copyData,区别就在于对这两个阶段的处理方式不同。
阻塞IO具体流程如下: 
大量的时间都耗费在等待waitData和 copyData上,而阻塞IO必须在原地等待,所以该模型的效率不高。
在TCP程序中使用该模型会明显感觉到效率低,一个客户端没有结束前,其他客户端是无法连接成功的。
多线程/多进程
在学习了线程和进程之后,我们可以将接受请求、收发数据拆分到不同线程中,来保证每一个客户端能够同时享受服务。
多线程虽然实现了并发访问,但是本质上并没有解决IO的阻塞问题,仅仅是把阻塞代码丢给另外一个线程,来避开了IO阻塞问题。
另一个问题是线程的创建时需要消耗系统资源的,所以我们不可能无限的去开启线程来处理客户端。
优点:解决了服务器不能并发处理客户端请求的问题
弊端:客户并发量太大将导致系统资源耗尽,并且没有解决阻塞问题
线程池
这就有了线程池,进程池,需要思考的是,线程池就一定比直接开线程效率高吗?
并不是,线程池主要功能是,限制线程的最大数量,保证服务器稳定运行,以及避免重复的创建和销毁线程,可以起到一些优化效果,但是对于IO效率是没有太大影响的。
优点:保证了服务器的稳定运行,减少频繁创建销毁线程的开销
弊端:当客户并发量高出系统承受线程数量极限时,后续的客户端将无法正常访问
上述解决方案都提高了效率,但是本质上还是属于阻塞IO模型。只是回避了IO阻塞问题。
并且由于GIL锁的存在,TCP程序中使用多线程不如单线程效率更高,但如何使得单线程可以并发处理多个客户端的请求呢,这便需要非阻塞IO了
非阻塞IO
非阻塞即 即时遇到IO操作不会进入阻塞状态。
例如:当发起了一个recv调用时,如果数据已经准备好了,就直接返回数据,如果没有准备好就返回错误信息,而recv函数将不会有任何阻塞效果,这样一来,就可以完全避开阻塞,在数据没有准备好的时候去执行其他任务,以此来提高效率。
非阻塞IO模型流程如下:

其中两个问题需要考虑:
1.如何使得socket变成非阻塞
socket.setblock(False)
2.如何获知数据没有准备好
捕获异常BlockingIOError
案例:
# 服务器
import socket
import time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind(("127.0.0.1",9898))
s.listen()
s.setblocking(False)
r_list = []
while True:
time.sleep(0.1)
try:
c,addr = s.accept()
r_list.append(c)
except BlockingIOError:
print("干点别的...",len(r_list))
for c in r_list:
try:
data = c.recv(1024)
if not data:
c.close()
r_list.remove(c)
print("断开连接........")
continue
print(data.decode("utf-8"))
c.send(data.upper())
except BlockingIOError:
continue
except ConnectionResetError:
c.close()
r_list.remove(c)
print("断开连接........")
#客户端:
import socket
import os
c = socket.socket()
c.connect(("127.0.0.1",9898))
while True:
data = "%s hello" % os.getpid()
if not data:continue
c.send(data.encode("utf-8"))
msg = c.recv(1024)
print(msg.decode("utf-8"))
改进1:
上述代码可以完成单线程并发处理多个客户端,但是有一个影藏的bug,即在迭代期间操作容器元素,因为需要在客户端断开连接后从列表中删除客户端对象。
测试:
# 无法正确删除
li = [1,2,3,4,5]
for i in li:
print(i)
li.remove(i)
print(li)
# 字典直接抛出异常
dic = {"name":"jack"}
for k in dic:
dic.pop(k)
# 解决方案1:将要删除的元素存储到一个新列表中 遍历完成后在统一删除
li = [1,2,3,4,5]
rm_list = []
for i in li[:]:
rm_list.append(i)
for i in rm_list:
li.remove(i)
print(li)
# 解决方案2:遍历新列表 删除旧列表
li = [1,2,3,4,5]
for i in li[:]:
print(i)
li.remove(i)
print(li)
改进2:
思考c.send(data.upper())代码是不是阻塞的?
send是把数据交给操作系统缓存,也就是CopyData阶段,也是一个阻塞操作,而由于当前为非阻塞模式,在一些极端情况下可能会抛出BlockingIOError,例如缓冲区没有足够的容量时。以防万一,我们不能直接在recv下面发送数据,因为异常被捕获后直接执行了continue导致数据丢失。解决的方案把要发送的数据先存储到容器中统一发送。
import socket
import time
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind(("127.0.0.1",9898))
s.listen()
s.setblocking(False)
r_list = []
w_list = []
while True:
time.sleep(0.05)
try:
c,addr = s.accept()
r_list.append(c)
except BlockingIOError:
print("干点别的...",len(r_list))
# 接收数据
for c in r_list:
try:
data = c.recv(1024)
if not data:
c.close()
r_list.remove(c)
print("断开连接........")
continue
print(data.decode("utf-8"))
w_list.append((c,data.upper())) # 把要发送的数据存储到容器中
except BlockingIOError:
continue
except ConnectionResetError:
c.close()
r_list.remove(c)
print("断开连接........")
# 发送数据
for item in w_list[:]:
try:
item[0].send(item[1])
w_list.remove(item)
except BlockingIOError: # 缓冲区不足 导致阻塞
continue
except ConnectionResetError: # 客户端异常断开
item[0].close()
w_list.remove(item)
r_list.remove(item[0])
至此我们就基于非阻塞IO模型编写出了一个支持单线程并发的TCP程序,并且效率非常高,但是问题在于,改程序将导致CPU被大量的占用,并且很多时候是无效的占用,机试没有任何客户端需要处理也处于疯狂的循环中。因为要不断的去循环系统数据是否准备好。
IO多路复用
多路复用最也是要用单线程来处理客户端并发,与其他模型相比多出了select这个角色,
程序不再直接问系统要数据,而是先发起一个select调用,select会阻塞直到其中某个socket准备就绪,此时应用程序再发起系统调用来获取数据,由于select已经帮我们确认了某个socket一定是就绪了,所以后续的recv send等操作可以立即完成,不会阻塞。
简单的说,select相当于一个中间者,专门帮你看着socket,哪个socket准备好了select就返回哪个。
你可以把select当做托儿所,把你的socket交给它看管,当某个socket要上厕所或要吃饭时,select会把它交给你。

案例:
# 服务器
import socket
import time
import select
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
s.bind(("127.0.0.1",9898))
s.listen()
s.setblocking(False)
r_list = [s]
w_list = []
datas = {}
while True:
reads,writes,_ = select.select(r_list,w_list,[])
# 处理可读的socket 即可以执行recv的
for i in reads:
if i == s:
c,addr = i.accept()
r_list.append(c)
else:
try:
data = i.recv(1024)
if not data:
r_list.remove(i)
continue
w_list.append(i)
datas[i] = data.upper()
except ConnectionResetError:
r_list.remove(i)
# 处理可写的
for i in writes:
try:
i.send(datas.pop(i))
except ConnectionResetError:
i.close()
datas.pop(i)
r_list.remove(i)
finally:
w_list.remove(i)
#客户端
import socket
import os
import time
c = socket.socket()
c.connect(("127.0.0.1",9898))
while True:
time.sleep(0.2)
data = "%s hello" % os.getpid()
if not data:continue
c.send(data.encode("utf-8"))
msg = c.recv(1024)
print(msg.decode("utf-8"))
在Cpython中由于有GIL 所以 协程或者是 多路复用的效率都会高于线程或线程池。
异步IO



浙公网安备 33010602011771号