Python学习笔记:线程,进程,协程。
一、线程(Thread)
1、定义:线程是操作系统能进行运算调度的最小单位,它包含在进程中,是进程的实际运作单位,一条线程是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。简单理解:线程是一系列指令的集合,操作系统通过这些指令调用硬件。
2、同一个线程中的所有线程共享同一个内存空间资源,
3、Python的多线程是伪多线程,是利用CPU上下文切换的方式造成并发效果,其实同时运行的只有一个线程。如下面的图1。
4、多线程代码:
import threading import time class MyThread(threading.Thread): """ # 用自定义一个子类的方式来启动线程 """ def __init__(self, n): super(MyThread, self).__init__() self.n = n def run(self): print("你好,%s" % self.n) time.sleep(2) start_time = time.time() thread_list = [] # 启动50个线程 for i in range(50): t1 = MyThread("t%s" % i) t1.start() thread_list.append(t1) # 等待所有线程执行完毕后主线程再继续执行 for i in thread_list: i.join() print("总共执行时间:%s" % float(time.time() - start_time))
5、全局解释器锁(GIL)
5.1、定义:GIL 是最流行的 CPython 解释器(平常称为 Python)中的一个技术术语,中文译为全局解释器锁,其本质上类似操作系统的 Mutex(即互斥锁,意思是我修改的时候你不能修改,也就是锁的意思)
5.2、功能:在 CPython 解释器中执行的每一个 Python 线程,都会先锁住自己,以阻止别的线程执行,这样在同个时间一个CPU只执行一个线程。当然,CPython 不可能容忍一个线程一直独占解释器,check interval 机制会在一个时间段后释放前面一个线程的全局锁执行下一个线程,以达到轮流执行线程的目的。这样一来,用户看到的就是“伪”并行,即 Python 线程在交替执行,来模拟真正并行的线程。
5.3、CPython 引进 GIL,可以最大程度上规避类似内存管理这样复杂的竞争风险问题,有了 GIL,并不意味着无需去考虑线程安全,因为即便 GIL 仅允许一个 Python 线程执行,但别忘了 Python 还有 check interval 这样的抢占机制。所以就要引入线程锁的机制,保证同个时间只有一个线程修改数据。
《图1》
5.4:线程锁的代码如下
import threading import time num = 0 lock_obj = threading.Lock() def run(): # 申请锁,使别的线程进不来 lock_obj.acquire() global num time.sleep(1.1) num = num + 1 # 解锁,解锁后别的线程可以进来 lock_obj.release() t_list = [] start_time = time.time() # 启动1000个线程 for i in range(100): t1 = threading.Thread(target=run) t1.start() t_list.append(t1) for i in t_list: i.join() time.sleep(3) print("num:%d" % num) print("time:%f" % float(time.time() - start_time))
6、递归锁:一个锁套另外一个锁,形成锁止循环,这种情况就要用到递归锁RLOCK
import threading, time def run1(): print("grab the first part data") lock.acquire() global num num += 1 lock.release() return num def run2(): print("grab the second part data") lock.acquire() global num2 num2 += 1 lock.release() return num2 def run3(): lock.acquire() res = run1() print('--------between run1 and run2-----') res2 = run2() lock.release() print(res, res2) num, num2 = 0, 0 # 这里如果用Lock()就会无限循环,找不到具体用哪个钥匙打开锁,如果用RLock就不会,如果又多重锁嵌套的情况一定要用递归锁 lock = threading.Lock() for i in range(1): t = threading.Thread(target=run3) t.start() while threading.active_count() != 1: print("当前活跃的线程数:",threading.active_count()) else: print('----all threads done---') print("打印num和num2:",num, num2)
7、信号量(Semaphore):允许同时间最多几个线程进入执行,每出来一个进去一个,同时保持预先设置的线程最大允许数量。
import threading, time def run(n): semaphore.acquire() time.sleep(1) print("run the thread: %s\n" % n) semaphore.release() if __name__ == '__main__': semaphore = threading.BoundedSemaphore(5) # 最多允许5个线程同时运行 for i in range(22): t = threading.Thread(target=run, args=(i,)) t.start() while threading.active_count() != 1: pass # print threading.active_count() else: print('----all threads done---') #print(num)
8、事件(Event)
8.1、定义:通过标识位和状态,来实现线程之间的交互。简单说,就是一个标志位,只有两种状态,一种是设置(Event.set()),一直是没有设置(Event.clear())。
8.2、Event类还有两个方法,wait()等待被设定,isset()判断是否被设定
8.3、以下代码实现一个简单事件,一个线程控制红绿灯,另外一个线程控制车子,当红绿灯是红色的时候,车子停止,绿的时候,车子行驶的效果
import time import threading event = threading.Event() def lighter(): count = 0 event.set() # 刚开始的标识位先设置绿灯 while True: if 5 < count < 10: # 改成红灯 event.clear() # 把标志位清了 print("\033[41;1mred light is on....\033[0m") elif count > 10: event.set() # 变绿灯 count = 0 else: print("\033[42;1mgreen light is on....\033[0m") time.sleep(1) count += 1 def car(name): while True: if event.is_set(): # 代表绿灯 print("[%s] running..." % name) time.sleep(1) else: print("[%s] sees red light , waiting...." % name) event.wait() print("\033[34;1m[%s] green light is on, start going...\033[0m" % name) light = threading.Thread(target=lighter, ) light.start() car1 = threading.Thread(target=car, args=("Tesla",)) car1.start()
9、守护线程
9.1、定义:即服务于非守护线程的线程,在非守护线程终止运行后被终止,这里说的终止不是代码结束,而是主线程要等非守护线程全部运行完毕才能终止(回收资源)。所以被标识为守护线程的线程,其实是可以先考虑被终止的线程,
9.2、注意:无论是进程还是线程,都遵循:守护xx会等待主xx运行完毕后被销毁。需要强调的是:运行完毕并非终止运行。
from threading import Thread import time def foo(): print(123) time.sleep(2) # 注意,这里因为主线程已经接受了,所以被标识为守护线程的t1,就不会输出end123 print("end123") def bar(): print(456) time.sleep(1) print("end456") t2 = Thread(target=bar) t1 = Thread(target=foo) t2.start() t1.daemon = True t1.start() print("main-------")
二、进程(Progress)
1、定义:一个程序对各资源管理和调用的集合就是进程,比如QQ对网卡、内存、硬盘的调度和管理。对于操作系统来说,某一个进程是统一的整体。进程操作CPU就要先创建一个线程。进程本身是一个资源集合,执行需要靠线程。
2、为什么要用多进程?
2.1、IO操作(读硬盘,网络Socke等操作)不占用CPU,计算(算术计算等)才占用CPU,python的多线程本质上是单线程的,所以遇到需要大量CPU密集操作的情况不适合用python多线程,而大量IO的操作比较适合python多线程。
2.2、Python的多线程是伪多线程,本质是单线程,如果要实现多线程的效果,怎么办?那么就要用到多进程,CPU的一核运行一个进程,如果8核,那么就是八个线程(每个进程执行一个主线程)在跑,大大提高了工作效率。
3、多进程优点:因为进程之间本身无法互相访问内存空间,所以不存在锁的问题。
缺点:多进程共享数据上增加了一定难度。
4、每一个进程都是他的父进程启动的,可以用os.getppid()查看父亲进程的ID,用os.getpid()查看本进程的ID
5、多进程的代码:
from multiprocessing import Process import os def run(name): print("进程 %s 的ID是%s" % (name,os.getpid())) if __name__ == '__main__': # 启动十个进程 for i in range(10): p = Process(target=run, args=("P%i" % i,)) p.start()
6、多进程之间的通信:因为进程的内存是独立的,所以原则上A线程要访问B线程的数据是无法访问的,如果一定要访问可以用以下中间件,
6.1、进程专用队列(Queue):它和线程队列不同之处在于,线程队列只能在本进程里面的线程才能访问,如果出了进程就不能用了。但是进程专用队列是可以在进程之间使用的。将进程专用队列传参给进程,这时是克隆了一个队列对象传参,实际是两个队列对象,当子线程往自己的队列中放数据以后进程专用队列对象通过反序列化的操作将数据又返回给父进程,虽然是两个队列对象但是看起来就像一个对象一样。所以这里不存在锁的问题,每个进程实际上都修改的是自己的队列对象。此对象用来通信,不能修改共享数据
from multiprocessing import Process,Queue import os def run(name,q): q.put("hello %s" % name) print("进程 %s 的ID是%s" % (name,os.getpid())) if __name__ == '__main__': q = Queue() list_obj = [] # 启动十个进程 for i in range(10): p = Process(target=run, args=("P%i" % i,q)) list_obj.append(p) p.start() for i in list_obj: # 等待进程全部执行完毕 i.join() for i in range(q.qsize()): print("得到的进程序列:", q.get_nowait())
6.2、管道(Pipe):相当于电话线,一头连着父进程,一头连着子进程,相当于建立了soket连接通信。此对象用来通信,不能修改共享数据
from multiprocessing import Process,Pipe import os def run(name,cc): cc.send("我是进程 %s 的ID是%s" % (name,os.getpid())) cc.send("我是进程 %s 的ID是%s" % (name,os.getpid())) print(cc.recv()) if __name__ == '__main__': # 创建一个管道,会返回管道的两头,一头给父进程用,一头给子进程用 parent_conn,child_conn = Pipe() p = Process(target=run, args=("P1", child_conn)) p.start() print(parent_conn.recv()) print(parent_conn.recv()) # # 子线程只发送了两条数据,所以这一条会卡住等待子线程继续发送 # print(parent_conn.recv()) # 以下这一条,在子线程不join的情况下也是可以接收到的 parent_conn.send("我是父进程!")
6.3、Manager:可以实现多进程之间真正的数据共享。而且不用加锁,因为Manager中已经默认加锁了,不允许两个进程同时进去添加修改数据,其实现原理跟进程专用队列一样,都是克隆了一个Manager给子线程
# 多进程 Manager import os from multiprocessing import Process, Manager def run(m_dict1, m_list1): m_dict1[os.getpid()] = os.getpid() m_list1.append(os.getpid()) if __name__ == '__main__': m = Manager() # 进程间可以共享的字典对象 m_dict = m.dict() # 进程间可以共享的列表对象 m_list = m.list() p_list = [] for i in range(20): p = Process(target=run, args=(m_dict, m_list)) p.start() p_list.append(p) for i in p_list: i.join() print(m_dict) print(m_list)
6.4、进程的锁:虽然进程大部分情况下不存在操作同个资源的问题,但是也有例外的情况,这种情况就需要用到进程锁,比如以下代码,这里的公共资源即是屏幕,多进程同时对屏幕输出就需要加锁
from multiprocessing import Process, Lock def run(name, lock_obj): lock_obj.acquire() print("我的名字是:%s" % name) lock_obj.release() if __name__ == '__main__': lock = Lock() # 启动十个进程 for i in range(10): p = Process(target=run, args=("P%i" % i, lock)) p.start()
7、进程池(Pool):因为每一次启动一个进程,相当于克隆一份父进程,如果父进程很大,那么系统开销就很大,这里就要使用进程池,初始化固定数量的进程数量,共同承担多进程任务。
import time from multiprocessing import Pool, current_process, Queue, Process def run(): # 因为进程池里有3个可用进程,所以会每三个任务执行一次,然后执行下一批任务。 time.sleep(1) print(current_process().name) # 回调函数,这里一定要加一个形参,不然无法运行。 def exit_method2(arg): print("子进程程调用以后主,主进程将调用本回调方法",current_process().name,arg) if __name__ == '__main__': # 运行进程池里面同时放入3个进程 pool = Pool(processes=3) list_val = [] for i in range(10): pool.apply_async(func=run,callback=exit_method2) print("主进程即将结束") pool.close() print("继续等待所有子线程运行完毕...如果不加join那么pool.close()就会直接结束所有进程不会等待子进程") pool.join()
三、协程(Coroutine)
1、定义:用户态的轻量级的线程,又称为微线程。协程拥有自己寄存器上下文和栈,协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来时恢复到之前的寄存器上下文和栈,以此协程可以保存上一次的状态,进入上一次调用时的状态,进入上一次离开时所处逻辑流的位置。协程是跑在线程里面的,其原理是遇到IO操作(IO操作很耗时)就切换到下一个命令,IO操作完毕又切换回来,只留下CPU运算,这样就可以最大程度的提高速度。在CPU层面只又线程,CPU不知道协程的存在,协程是用户自己控制的。
2、协程的优点:
> 无需线程上下文切换的开销
> 无需原子操作锁定及同步的开销。因为本质是单线程。
> 方便切换控制流,简化编程模型
> 高并发+高扩展+低成本,一个CPU支持上万协程都没有问题,很适合并发处理。
3、协程的缺点:
> 无法利用单个CPU的多核,本质是单线程。需要和进程配合才能运行在多个CPU上,我们平时编写的大部分程序都没有这个必要,除非是CPU密集型程序。
> 进行阻塞(blocking)操作时会阻塞掉整个程序。
4、用yield实现简单协程:
import time def consumer(name): print("--->%s starting eating baozi..." % name) while True: new_baozi = yield # 含有yield的函数consumer(name)只有在外部代码con.__next__()的时候才会执行,一直到yield为止程序流将切到外面。 print("[%s] is eating baozi %s" % (name, new_baozi)) #time.sleep(1) def producer(): r = con.__next__() # 碰到__next__()则程序流程切换到含有yield标记的函数内部。 r = con2.__next__() n = 0 while n < 5: n += 1 con.send(n) # 碰到send函数则回到上一次yield退出的地方,并且将seed的参数赋给yield的位置。 con2.send(n) time.sleep(1) print("\033[32;1m[producer]\033[0m is making baozi %s" % n) if __name__ == '__main__': con = consumer("c1") # 这里仅分配con的内存空间,但是因为内部含有yield标记,所以不进入执行,只有碰到__next__()的时候才进入。 con2 = consumer("c2") # 同上 p = producer()
5、安装第三方greenlet库,此库是封装的协程库,可以用来手动切换协程:
1、如果遇到“Microsoft Visual C++ 14.0 is required ”无法安装,可以参考这个文章:https://www.jianshu.com/p/7b24274c569a
2、注意一点,操作系统中不能有别的C++版本,先卸载以后再安装以上教程中的C++版本。
3、安装完C++ 14.0后,在Pycharm中的设置路径:File | Settings | Project: python_base | Project Interpreter,在 Project Interpreter搜索greenlet安装即可。
4、使用greenlet库的协程:
from greenlet import greenlet def test1(): print(12) gr2.switch() # 手动切换到gr2协程 print(34) gr2.switch() def test2(): print(56) gr1.switch() print(78) gr1 = greenlet(test1) # 启动1个协程 gr2 = greenlet(test2) # 启动1个协程 gr1.switch() # 手动切换到gr1协程
6、安装第三方库Gveent库,此库是对greenlet的再次封装,可以自动切换协程,当程序遇到IO操作时自动切换。可以利用其实现并发同步和异步编程
import gevent # 本程序因为使用了协程自动切换,所以总执行市场为2秒,不需要3秒,就省了1秒的时间 def foo(): print('Running in foo') gevent.sleep(2) # 遇到IO操作切换到下面一个协程 print('Explicit context switch to foo again') def bar(): print('Explicit精确的 context内容 to bar') gevent.sleep(1) # 遇到IO操作切换到下面一个协程 print('Implicit context switch back to bar') def func3(): print("running func3 ") # 遇到IO操作自动切换到第一个协程, # 但因为第一个协程还在等待,所以又自动切换到第二个协程, # 第二个协程也在等待,所以自动切换到自己并且输出下面一句话。 gevent.sleep(0) print("running func3 again ") gevent.join_all([ gevent.spawn(foo), # 生成,1个自动协程 gevent.spawn(bar), # 生成,1个自动协程 gevent.spawn(func3), # 生成,1个自动协程 ])
一个简单的Gveent爬虫,实现遇到IO的自动切换,这样爬取速度会很快:
from urllib import request import gevent, time from gevent import monkey # 把当前程序的所有的io操作给我单独的做上标记,如果不加这句话gevent是检测不到urllib包内部的IO操作的也就无法做自动切换 monkey.patch_all() def f(url): print('GET: %s' % url) resp = request.urlopen(url) data = resp.read() print('%d bytes received from %s.' % (len(data), url)) # 三个反爬等级很低的网站 urls = ['http://www.pelle.cn/', 'http://it.sxcqjykj.cn/', 'https://www.niu.com/'] time_start = time.time() for url in urls: f(url) print("同步 总耗时", time.time() - time_start) print() print() async_time_start = time.time() gevent.joinall([ gevent.spawn(f, urls[0]), gevent.spawn(f, urls[1]), gevent.spawn(f, urls[2]), ]) print("异步 总耗时", time.time() - async_time_start)
7、使用协程,实现多协程的Socket服务器
######## Server ############# import sys import socket import time import gevent from gevent import socket, monkey monkey.patch_all() def server(port): s = socket.socket() s.bind(('0.0.0.0', port)) s.listen(500) while True: cli, addr = s.accept() gevent.spawn(handle_request, cli) def handle_request(conn): try: while True: data = conn.recv(1024) print("recv:", data) conn.send(data) if not data: conn.shutdown(socket.SHUT_WR) except Exception as ex: print(ex) finally: conn.close() if __name__ == '__main__': server(54971) ######## Client ############# import socket HOST = 'localhost' # The remote host PORT = 54971 # The same port as used by the server s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((HOST, PORT)) while True: msg = bytes(input(">>:"), encoding="utf8") s.sendall(msg) data = s.recv(1024) print('Received', data) s.close()
四、事件驱动的编程范式:
1、定义:这里程序的执行流由外部事件来决定,特点是包含一个事件循环,当外部事件发生时使用回调来触发相应的处理,另外两个常见范式是单线程同步编程和多线程编程。
关于三种编程范式的执行流程图解,如下图,Asynchronous异步就是事件驱动编程,这里用到了协程实现。
2、通常服务器处理模型有以下三种模型:
> 接到请求后创建一个新线程处理(涉及到线程同步,有可能面临死锁等问题),
> 接到请求后创建一个新进程处理(开销大,性能差但是实现简单),
> 接到请求后创建一个协程处理(即收到请求,放入事件列表,让主进程通过非阻塞IO的方式来处理请求,写的代码比前面两种都要复杂,但这是网络服务器普遍采用的方式)
3、以UI编程为例,如何知晓界面上用户是否点击了某个元素呢?有下面两个方法可以
> 创建一个不断循环的线程,用来监控用户的行为,如果点击了,就触发事件,但缺点是:如果收到一个点击后处理事件太长,那么就会形成阻塞,其他点击行为就无法及时的处理。
> 事件驱动模型,有一个事件消息队列,用户点击鼠标后,往这个队列增加一个消息,有一个线程专门从队列中取出消息,根据事件不同调用相应的函数解决,每一个消息都各自保存处理函数的指针,这样每个消息都有独立的处理函数。
4、先来看看程序处理流程都有几种I/O处理方式:
> 阻塞I/O(Blocking I/O):(同步的),用户程序申请I/O数据,是向操作系统的相关I/O接口申请数据,然后操作系统取回数据以后放在内核内存空间,再从内核空间拷贝到用户内存空间,这个过程有两个等待时间,这个等待时间整个线程处于阻塞状态
> 非阻塞I/O(Nonblocking I/O):(同步的),用户线程发出I/O请求以后会不断询问内核是否所有数据都准备好了,内核马上回应错误则说明未准备好,不需要阻塞,此时用户进程可以干别的事情,如果准备好了则将数据从内核空间拷贝到用户空间,这个过程是阻塞的,如果数据很多,那么整个拷贝过程就会卡很久。整个过程的特点是用户进程需要不断询问内核是否将数据准备好。
>同步I/O多路复用:(同步的),也就是事件驱动I/O,有三种模式,select,poll,epoll。select和poll单线程就可以同时处理多个网络连接的I/O,其原理是select和poll不断的轮询所有Socket,这个过程是阻塞的,通过轮询获得哪个数据准备好了然后将数据从内核空间取回。与第一种阻塞I/O的区别是,这里是一次性给多个Socket给内核委托其监控这些Socket状态,而第一种是一次只给一个Socket,所以如果多连接的情况用这种是更快的。select和poll的区别是,前者有规定监测多少数量的sockte而poll没有规定数量,epoll与前面两者的区别就在于,不需要一直轮询,内核会给epoll一个socket连接,某个socket有数据的时候通知epoll取回,然后epoll只需要拿着这个连接找到有数据的那个socket就行,不需要一直保持轮询。Nginx就是同步IO多路复用,它不是异步IO!
>异步I/O (Asynchronous I/O):(异步的),程序进程发出数据请求以后马上可以返回,内核负责将准备好的数据拷贝到用户空间,这个过程完全不会阻塞。Notejs就是异步IO的。
> I/O流程的总结:
I/O操作过程分成两个步骤:第一、系统准备数据,第二、数据从系统内存拷贝到用户进程内存。如果处理不好的话,那么第一和第二步骤都会有阻塞。
阻塞和非阻塞区别:前者会一直阻塞等待数据返回,而后者如果内核在准备数据就会马上返回。
同步和异步的区别:阻塞IO、非阻塞IO和IO多路复用,都属于同步IO,IO多路复用虽然是部分非阻塞的,但是数据从内核空间拷贝到用户空间需要用户进程去取数据所以是需要阻塞的,而异步IO完全不需要阻塞。
> 同步、异步、阻塞、非阻塞之间的区别:
1.同步与异步
同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)
所谓同步,就是在发出一个*调用*时,在没有得到结果之前,该*调用*就不返回。但是一旦调用返回,就得到返回值了。
换句话说,就是由*调用者*主动等待这个*调用*的结果。
而异步则是相反,*调用*在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在*调用*发出后,*被调用者*通过状态、通知来通知调用者,或通过回调函数处理这个调用。典型的异步编程模型比如Node.js
举个通俗的例子:
你打电话问书店老板有没有《分布式系统》这本书,如果是同步通信机制,书店老板会说,你稍等,”我查一下",然后开始查啊查,等查好了(可能是5秒,也可能是一天)告诉你结果(返回结果)。
而异步通信机制,书店老板直接告诉你我查一下啊,查好了打电话给你,然后直接挂电话了(不返回结果)。然后查好了,他会主动打电话给你。在这里老板通过“回电”这种方式来回调。
2. 阻塞与非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
还是上面的例子,
你打电话问书店老板有没有《分布式系统》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结果,如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有返回结果。
在这里阻塞与非阻塞与是否同步异步无关(同步也可能是非阻塞的,比如上面的IO多路复用,提交数据以后马上返回没有阻塞挂起线程,之后要自己check检测是否有数据,从系统取回数据的时候又是阻塞的)。跟老板通过什么方式回答你结果无关。
四、线程和进程的区别
1、同个进程的线程之间共享内存空间,包括数据交换和通信,但不同进程之间的内存是独立的
2、子进程是克隆了一份父进程的数据,子进程之间是互相独立的,不能互相访问,数据也不能共享。
3、两个进程要通信,必须通过一个中间进程代理来实现。
4、一个线程可以操作同一个进程中的其他线程,但是进程只能操作子进程
5、对主线程的修改,可能会影响到其他的子线程,因为他们共享内存数据,但对主进程的修改,不会影响其他子进程。
6、进程和线程之间本质上不具备可比性,进程是一系列资源的总和,进程需要依靠线程执行,每个进程又至少包含一个主线程,而线程是一系列指令集的集合,是放到CPU中执行的。