python 进程, 线程 ,协程,锁,协程应用到爬虫的讲解
锁:
1.同步锁
需求:对一个全局变量,开启100个线程,每个线程都对该全局变量做减1操作;
不加锁,代码如下:
import time import threading num = 100 #设定一个共享变量 def addNum(): global num #在每个线程中都获取这个全局变量 #num-=1 temp=num time.sleep(0.1) num =temp-1 # 对此公共变量进行-1操作 thread_list = [] for i in range(100): t = threading.Thread(target=addNum) t.start() thread_list.append(t) for t in thread_list: #等待所有线程执行完毕 t.join() print('Result: ', num)
num
减为0,第一个线程执行addNum
遇到I/O阻塞后迅速切换到下一个线程执行addNum
,由于CPU执行切换的速度非常快,在0.1秒内就切换完成了,这就造成了第一个线程在拿到num变量后,在time.sleep(0.1)
时,其他的线程也都拿到了num变量,所有线程拿到的num值都是100,所以最后减1操作后,就是99。加锁实现。import time import threading num = 100 #设定一个共享变量 def addNum(): with lock: global num temp = num time.sleep(0.1) num = temp-1 #对此公共变量进行-1操作 thread_list = [] if __name__ == '__main__': lock = threading.Lock() #由于同一个进程内的线程共享此进程的资源,所以不需要给每个线程传这把锁就可以直接用。 for i in range(100): t = threading.Thread(target=addNum) t.start() thread_list.append(t) for t in thread_list: #等待所有线程执行完毕 t.join() print("result: ",num)
注意:
with lock
是lock.acquire()
(加锁)与lock.release()
(释放锁)的简写。import threading R=threading.Lock() R.acquire() ''' 对公共数据的操作 ''' R.release()
GIL vs Lock
疑问:
机智的同学可能会问到这个问题,就是既然你之前说过了,Python已经有一个GIL来保证同一时间只能有一个线程来执行了,为什么这里还需要lock?
首先我们需要达成共识:锁的目的是为了保护共享的数据,同一时间只能有一个线程来修改共享的数据
然后,我们可以得出结论:保护不同的数据就应该加不同的锁。
最后,问题就很明朗了,GIL 与Lock是两把锁,保护的数据不一样,前者是解释器级别的(当然保护的就是解释器级别的数据,比如垃圾回收的数据),后者是保护用户自己开发的应用程序的数据,很明显GIL不负责这件事,只能用户自定义加锁处理,即Lock
详细的:
因为Python解释器帮你自动定期进行内存回收,你可以理解为python解释器里有一个独立的线程,每过一段时间它起wake up做一次全局轮询看看哪些内存数据是可以被清空的,此时你自己的程序 里的线程和 py解释器自己的线程是并发运行的,假设你的线程删除了一个变量,py解释器的垃圾回收线程在清空这个变量的过程中的clearing时刻,可能一个其它线程正好又重新给这个还没来及得清空的内存空间赋值了,结果就有可能新赋值的数据被删除了,为了解决类似的问题,python解释器简单粗暴的加了锁,即当一个线程运行时,其它人都不能动,这样就解决了上述的问题, 这可以说是Python早期版本的遗留问题。
2. 死锁与递归锁
所谓死锁:是指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态,或系统产生了死锁。这此永远在互相等待的进程称死锁进程。
from threading import Thread,Lock import time mutexA=Lock() mutexB=Lock() class MyThread(Thread): def run(self): self.func1() self.func2() def func1(self): mutexA.acquire() print('\033[41m%s 拿到A锁\033[0m' %self.name) mutexB.acquire() print('\033[42m%s 拿到B锁\033[0m' %self.name) mutexB.release() mutexA.release() def func2(self): mutexB.acquire() print('\033[43m%s 拿到B锁\033[0m' %self.name) time.sleep(2) mutexA.acquire() print('\033[44m%s 拿到A锁\033[0m' %self.name) mutexA.release() mutexB.release() if __name__ == '__main__': for i in range(10): t=MyThread() t.start() ''' Thread-1 拿到A锁 Thread-1 拿到B锁 Thread-1 拿到B锁 Thread-2 拿到A锁 然后就卡住,死锁了 '''
解决死锁的方法
避免产生死锁的方法就是用递归锁,在python中为了支持在同一线程中多次请求同一资源,python提供了可重入锁RLock
。
这个RLock
内部维护着一个Lock和一个counter变量,counter记录了acquire
(获得锁)的次数,从而使得资源可以被多次require。直到一个线程所有的acquire
都被release
(释放)后,其他的线程才能获得资源。上面的例子如果使用RLock
代替Lock
,就不会发生死锁的现象了。
mutexA=mutexB=threading.RLock()
#一个线程拿到锁,counter加1,该线程内又碰到加锁的情况,则counter继续加1,这期间所有其他线程都只能等待,等待该线程释放所有锁,即counter递减到0为止。
同进程的信号量一样。
用一个粗俗的例子来说,锁相当于独立卫生间,只有一个坑,同一时刻只能有一个人获取锁,进去使用;而信号量相当于公共卫生间,例如有5个坑,同一时刻可以有5个人获取锁,并使用。
Semaphore
管理一个内置的计数器,每当调用acquire()
时,内置计数器-1;调用release()
时,内置计数器+1;计数器不能小于0,当计数器为0时,acquire()
将阻塞线程,直到其他线程调用release()
。
实例:
同时只有5个线程可以获得Semaphore,即可以限制最大连接数为5:
import threading import time sem = threading.Semaphore(5) def func(): if sem.acquire(): #也可以用with进行上下文管理 print(threading.current_thread().getName()+"get semaphore") time.sleep(2) sem.release() for i in range(20): t1 = threading.Thread(target=func) t1.start()
利用with
进行上下文管理:
import threading import time sem = threading.Semaphore(5) def func(): with sem: print(threading.current_thread().getName()+"get semaphore") time.sleep(2) for i in range(20): t1 = threading.Thread(target=func) t1.start()
注:信号量与进程池是完全不同一的概念,进程池Pool(4)
最大只能产生4个进程,而且从头到尾都只是这4个进程,不会产生新的,而信号量是产生一堆线程/进程。
4. 事件Event
同进程的一样
线程的一个关键特性是每个线程都是独立运行且状态不可预测。如果程序中的其他线程通过判断某个线程的状态来确定自己下一步的操作,这时线程同步问题就会变得非常棘手,为了解决这些问题我们使用threading库中的Event
对象。
Event
对象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。在初始情况下,Event对象中的信号标志被设置为假。如果有线程等待一个Event对象,而这个Event对象的标志为假,那么这个线程将会被 一直阻塞直至该 标志为真。一个线程如果将一个Event对象的信号标志设置为真,它将唤醒所有等待这个Event对象的线程。如果一个线程等待一个已经被 设置 为真的Event对象,那么它将忽略这个事件,继续执行。
Event对象具有一些方法:
event = threading.Event()
#产生一个事件对象
-
event.isSet()
:返回event状态值; -
event.wait()
:如果event.isSet() == False
,将阻塞线程; -
event.set()
:设置event的状态值为True,所有阻塞池的线程进入就绪状态,等待操作系统高度; -
event.clear()
:恢复event的状态值False。
应用场景:
例如,我们有多个线程需要连接数据库,我们想要在启动时确保Mysql服务正常,才让那些工作线程去连接Mysql服务器,那么我们就可以采用threading.Event()
机制来协调各个工作线程的连接操作,主线程中会去尝试连接Mysql服务,如果正常的话,触发事件,各工作线程会尝试连接Mysql服务。
from threading import Thread,Event import threading import time,random def conn_mysql(): print('\033[42m%s 等待连接mysql。。。\033[0m' %threading.current_thread().getName()) event.wait() #默认event状态为False,等待 print('\033[42mMysql初始化成功,%s开始连接。。。\033[0m' %threading.current_thread().getName()) def check_mysql(): print('\033[41m正在检查mysql。。。\033[0m') time.sleep(random.randint(1,3)) event.set() #设置event状态为True time.sleep(random.randint(1,3)) if __name__ == '__main__': event=Event() t1=Thread(target=conn_mysql) #等待连接mysql t2=Thread(target=conn_mysql) #等待连接myqsl t3=Thread(target=check_mysql) #检查mysql t1.start() t2.start() t3.start() ''' 输出如下: Thread-1 等待连接mysql。。。 Thread-2 等待连接mysql。。。 正在检查mysql。。。 Mysql初始化成功,Thread-1开始连接。。。 Mysql初始化成功,Thread-2开始连接。。。 '''
注:
threading.Event
的wait
方法还可以接受一个超时参数,默认情况下,如果事件一直没有发生,wait
方法会一直阻塞下去,而加入这个超时参数之后,如果阻塞时间超过这个参数设定的值之后,wait
方法会返回。对应于上面的应用场景,如果mysql服务器一直没有启动,我们希望子线程能够打印一些日志来不断提醒我们当前没有一个可以连接的mysql服务,我们就可以设置这个超时参数来达成这样的目的:
上例代码修改后如下:
from threading import Thread,Event import threading import time,random def conn_mysql(): count = 1 while not event.is_set(): print("\033[42m%s 第 <%s> 次尝试连接。。。"%(threading.current_thread().getName(),count)) event.wait(0.2) count+=1 print("\033[45mMysql初始化成功,%s 开始连接。。。\033[0m"%(threading.current_thread().getName())) def check_mysql(): print('\033[41m正在检查mysql。。。\033[0m') time.sleep(random.randint(1,3)) event.set() time.sleep(random.randint(1,3)) if __name__ == '__main__': event=Event() t1=Thread(target=conn_mysql) #等待连接mysql t2=Thread(target=conn_mysql) #等待连接mysql t3=Thread(target=check_mysql) #检查mysql t1.start() t2.start() t3.start()
这样,我们就可以在等待Mysql服务启动的同时,看到工作线程里正在等待的情况。应用:连接池。
5. 定时器timer
定时器,指定n秒后执行某操作。
from threading import Timer def hello(): print("hello, world") t = Timer(1, hello) #1秒后执行任务hello t.start() # after 1 seconds, "hello, world" will be printed
6. 线程队列queue
queue队列:使用import queue
,用法与进程Queue
一样。
queue
下有三种队列:
queue.Queue(maxsize)
先进先出,先放进队列的数据,先被取出来;queue.LifoQueue(maxsize)
后进先出,(Lifo 意为last in first out),后放进队列的数据,先被取出来queue.PriorityQueue(maxsize)
优先级队列,优先级越高优先取出来。
举例:
先进先出:
import queue q=queue.Queue() q.put('first') q.put('second') q.put('third') print(q.get()) print(q.get()) print(q.get()) ''' 结果(先进先出): first second third '''
后进先出:
import queue q=queue.LifoQueue() q.put('first') q.put('second') q.put('third') print(q.get()) print(q.get()) print(q.get()) ''' 结果(后进先出): third second first '''
优先级队列:
import queue q=queue.PriorityQueue() #put进入一个元组,元组的第一个元素是优先级(通常是数字,也可以是非数字之间的比较),数字越小优先级越高 q.put((20,'a')) q.put((10,'b')) q.put((30,'c')) print(q.get()) print(q.get()) print(q.get()) ''' 结果(数字越小优先级越高,优先级高的优先出队): (10, 'b') (20, 'a') (30, 'c') '''
7. 协程
协程:是单线程下的并发,又称微线程、纤程,英文名:Coroutine。协程是一种用户态的轻量级线程,协程是由用户程序自己控制调度的。
需要强调的是:
1. python的线程属于内核级别的,即由操作系统控制调度(如单线程一旦遇到io就被迫交出cpu执行权限,切换其他线程运行)
2. 单线程内开启协程,一旦遇到io,从应用程序级别(而非操作系统)控制切换
对比操作系统控制线程的切换,用户在单线程内控制协程的切换,优点如下:
1. 协程的切换开销更小,属于程序级别的切换,操作系统完全感知不到,因而更加轻量级
2. 单线程内就可以实现并发的效果,最大限度地利用cpu。
要实现协程,关键在于用户程序自己控制程序切换,切换之前必须由用户程序自己保存协程上一次调用时的状态,如此,每次重新调用时,能够从上次的位置继续执行
(详细的:协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈)
7.1 yield实现协程
我们之前已经学习过一种在单线程下可以保存程序运行状态的方法,即yield,我们来简单复习一下:
- yiled可以保存状态,yield的状态保存与操作系统的保存线程状态很像,但是yield是代码级别控制的,更轻量级
- send可以把一个函数的结果传给另外一个函数,以此实现单线程内程序之间的切换 。
#不用yield:每次函数调用,都需要重复开辟内存空间,即重复创建名称空间,因而开销很大 import time def consumer(item): # print('拿到包子%s' %item) x=11111111111 x1=12111111111 x3=13111111111 x4=14111111111 y=22222222222 z=33333333333 pass def producer(target,seq): for item in seq: target(item) #每次调用函数,会临时产生名称空间,调用结束则释放,循环100000000次,则重复这么多次的创建和释放,开销非常大 start_time=time.time() producer(consumer,range(100000000)) stop_time=time.time() print('run time is:%s' %(stop_time-start_time)) #30.132838010787964 #使用yield:无需重复开辟内存空间,即重复创建名称空间,因而开销小 import time def init(func): def wrapper(*args,**kwargs): g=func(*args,**kwargs) next(g) return g return wrapper init def consumer(): x=11111111111 x1=12111111111 x3=13111111111 x4=14111111111 y=22222222222 z=33333333333 while True: item=yield # print('拿到包子%s' %item) pass def producer(target,seq): for item in seq: target.send(item) #无需重新创建名称空间,从上一次暂停的位置继续,相比上例,开销小 start_time=time.time() producer(consumer(),range(100000000)) stop_time=time.time() print('run time is:%s' %(stop_time-start_time)) #21.882073879241943
缺点:
协程的本质是单线程下,无法利用多核,可以是一个程序开启多个进程,每个进程内开启多个线程,每个线程内开启协程。
协程指的是单个线程,因而一旦协程出现阻塞,将会阻塞整个线程。
协程的定义(满足1,2,3就可以称为协程):
- 必须在只有一个单线程里实现并发
- 修改共享数据不需加锁
- 用户程序里自己保存多个控制流的上下文栈
- 附加:一个协程遇到IO操作自动切换到其它协程(如何实现检测IO,yield、greenlet都无法实现,就用到了gevent模块(select机制))
注意:yield切换在没有io的情况下或者没有重复开辟内存空间的操作,对效率没有什么提升,甚至更慢,为此,可以用greenlet来为大家演示这种切换。
7.2 greenlet实现协程
greenlet是一个用C实现的协程模块,相比与python自带的yield,它可以使你在任意函数之间随意切换,而不需把这个函数先声明为generator。
安装greenlet
模块
pip install greenlet
from greenlet import greenlet import time def t1(): print("test1,first") gr2.switch() time.sleep(5) print("test1,second") gr2.switch() def t2(): print("test2,first") gr1.switch() print("test2,second") gr1 = greenlet(t1) gr2 = greenlet(t2) gr1.switch() ''' 输出结果: test1,first test2,first #等待5秒 test1,second test2,second '''
可以在第一次switch时传入参数
from greenlet import greenlet import time def eat(name): print("%s eat food 1"%name) gr2.switch(name="alex") time.sleep(5) print("%s eat food 2"%name) gr2.switch() def play_phone(name): print("%s play phone 1"%name) gr1.switch() print("%s play phone 1" % name) gr1 = greenlet(eat) gr2 = greenlet(play_phone) gr1.switch(name="egon") #可以在第一次switch时传入参数,以后都不需要
注意:
greenlet
只是提供了一种比generator
更加便捷的切换方式,仍然没有解决遇到I/O自动切换的问题,而单纯的切换,反而会降低程序的执行速度。这就需要用到gevent
模块了。
7.3 gevent实现协程
gevent
是一个第三方库,可以轻松通过gevent实现并发同步或异步编程,在gevent
中用到的主要是Greenlet
,它是以C扩展模块形式接入Python的轻量级协程。greenlet
全部运行在主程操作系统进程的内部,但它们被协作式地调试。遇到I/O阻塞时会自动切换任务。
注意:
gevent
有自己的I/O阻塞,如:gevent.sleep()和gevent.socket()
;但是gevent
不能直接识别除自身之外的I/O阻塞,如:time.sleep(2)
,socket
等,要想识别这些I/O阻塞,必须打一个补丁:from gevent import monkey;monkey.patch_all()
。
-
需要先安装
gevent
模块
pip install gevent
-
创建一个协程对象g1
g1 =gevent.spawn()
spawn
括号内第一个参数是函数名,如eat,后面可以有多个参数,可以是位置实参或关键字实参,都是传给第一个参数(函数)eat的。
from gevent import monkey;monkey.patch_all() import gevent def eat(): print("点菜。。。") gevent.sleep(3) #等待上菜 print("吃菜。。。") def play(): print("玩手机。。。") gevent.sleep(5) #网卡了 print("看NBA...") # gevent.spawn(eat) # gevent.spawn(play) # print('主') # 直接结束 #因而也需要join方法,进程或现场的jion方法只能join一个,而gevent的joinall方法可以join多个 g1=gevent.spawn(eat) g2=gevent.spawn(play) gevent.joinall([g1,g2]) #传一个gevent对象列表。 print("主线程") """ 输出结果: 点菜。。。 玩手机。。。 ##等待大概3秒 此行没打印 吃菜。。。 ##等待大概2秒 此行没打印 看NBA... 主线程 """
注:上例中的
gevent.sleep(3)
是模拟的I/O阻塞。跟time.sleep(3)
功能一样。
同步/异步
import gevent def task(pid): """ Some non-deterministic task """ gevent.sleep(0.5) print('Task %s done' % pid) def synchronous(): #同步执行 for i in range(1, 10): task(i) def asynchronous(): #异步执行 threads = [gevent.spawn(task, i) for i in range(10)] gevent.joinall(threads) print('Synchronous:') synchronous() #执行后,会顺序打印结果 print('Asynchronous:') asynchronous() #执行后,会异步同时打印结果,无序的。
------------------------------------------------------------------------------------------------------------
8.爬虫应用
#协程的爬虫应用 from gevent import monkey;monkey.patch_all() import gevent import time import requests def get_page(url): print("GET: %s"%url) res = requests.get(url) if res.status_code == 200: print("%d bytes received from %s"%(len(res.text),url)) start_time = time.time() g1 = gevent.spawn(get_page,"https://www.python.org") g2 = gevent.spawn(get_page,"https://www.yahoo.com") g3 = gevent.spawn(get_page,"https://www.github.com") gevent.joinall([g1,g2,g3]) stop_time = time.time() print("run time is %s"%(stop_time-start_time))
上以代码输出结果:
GET: https://www.python.org GET: https://www.yahoo.com GET: https://www.github.com 47714 bytes received from https://www.python.org 472773 bytes received from https://www.yahoo.com 98677 bytes received from https://www.github.com run time is 2.501142978668213
应用:
通过gevent实现单线程下的socket并发,注意:from gevent import monkey;monkey.patch_all()
一定要放到导入socket模块之前,否则gevent无法识别socket的阻塞。
服务端代码:
from gevent import monkey;monkey.patch_all() import gevent from socket import * class server: def __init__(self,ip,port): self.ip = ip self.port = port def conn_cycle(self): #连接循环 tcpsock = socket(AF_INET,SOCK_STREAM) tcpsock.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) tcpsock.bind((self.ip,self.port)) tcpsock.listen(5) while True: conn,addr = tcpsock.accept() gevent.spawn(self.comm_cycle,conn,addr) def comm_cycle(self,conn,addr): #通信循环 try: while True: data = conn.recv(1024) if not data:break print(addr) print(data.decode("utf-8")) conn.send(data.upper()) except Exception as e: print(e) finally: conn.close() s1 = server("127.0.0.1",60000) print(s1) s1.conn_cycle()
客户端代码 :
from socket import * tcpsock = socket(AF_INET,SOCK_STREAM) tcpsock.connect(("127.0.0.1",60000)) while True: msg = input(">>: ").strip() if not msg:continue tcpsock.send(msg.encode("utf-8")) data = tcpsock.recv(1024) print(data.decode("utf-8"))
通过gevent实现并发多个socket客户端去连接服务端
from gevent import monkey;monkey.patch_all() import gevent from socket import * def client(server_ip,port): try: c = socket(AF_INET,SOCK_STREAM) c.connect((server_ip,port)) count = 0 while True: c.send(("say hello %s"%count).encode("utf-8")) msg = c.recv(1024) print(msg.decode("utf-8")) count+=1 except Exception as e: print(e) finally: c.close() # g_l = [] # for i in range(500): # g = gevent.spawn(client,'127.0.0.1',60000) # g_l.append(g) # gevent.joinall(g_l) #上面注释代码可简写为下面代码这样。 threads = [gevent.spawn(client,"127.0.0.1",60000) for i in range(500)] gevent.joinall(threads)
end
import queue
q=queue.PriorityQueue()
#put进入一个元组,元组的第一个元素是优先级(通常是数字,也可以是非数字之间的比较),数字越小优先级越高
q.put((20,'a'))
q.put((10,'b'))
q.put((30,'c'))
print(q.get())
print(q.get())
print(q.get())
'''
结果(数字越小优先级越高,优先级高的优先出队):
(10, 'b')
(20, 'a')
(30, 'c')
'''