day21-多并发编程基础(二)
今日要整理的内容有
1. 操作系统中线程理论
2.python中的GIL锁
3.线程在python中的使用
开始今日份整理
1. 操作系统中线程理论
1.1 线程引入背景
之前我们已经了解了操作系统中进程的概念,程序并不能单独运行,只有将程序装载到内存中,系统为它分配资源才能运行,而这种执行的程序就称之为进程。程序和进程的区别就在于:程序是指令的集合,它是进程运行的静态描述文本;进程是程序的一次执行活动,属于动态概念。在多道编程中,我们允许多个程序同时加载到内存中,在操作系统的调度下,可以实现并发地执行。这是这样的设计,大大提高了CPU的利用率。进程的出现让每个用户感觉到自己独享CPU,因此,进程就是为了在CPU上实现多道编程而提出的。
那么有了进程为什么还需要线程
进程有很多优点,它提供了多道编程,让我们感觉我们每个人都拥有自己的CPU和其他资源,可以提高计算机的利用率。很多人就不理解了,既然进程这么优秀,为什么还要线程呢?其实,仔细观察就会发现进程还是有很多缺陷的,主要体现在两点上:
-
进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
-
进程在执行的过程中如果阻塞,例如等待输入,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法执行。
如果这两个缺点理解比较困难的话,举个现实的例子也许你就清楚了:如果把我们上课的过程看成一个进程的话,那么我们要做的是耳朵听老师讲课,手上还要记笔记,脑子还要思考问题,这样才能高效的完成听课的任务。而如果只提供进程这个机制的话,上面这三件事将不能同时执行,同一时间只能做一件事,听的时候就不能记笔记,也不能用脑子思考,这是其一;如果老师在黑板上写演算过程,我们开始记笔记,而老师突然有一步推不下去了,阻塞住了,他在那边思考着,而我们呢,也不能干其他事,即使你想趁此时思考一下刚才没听懂的一个问题都不行,这是其二。
现在你应该明白了进程的缺陷了,而解决的办法很简单,我们完全可以让听、写、思三个独立的过程,并行起来,这样很明显可以提高听课的效率。而实际的操作系统中,也同样引入了这种类似的机制——线程。
注意:进程是资源分配的最小单位,线程是cpu调度的最小单位,每一个进程中至少一个线程
1.2 进程与线程的区别
进程与线程之间的区别主要是有以下几点
- 地址空间与其他资源(如打开文件):进程之间相互独立,同一个进程内线程相互共享,某个进程内的线程其他进程不可见。
- 通信:进程之间通信需要IPC,线程之间通信可以直接调用进程的数据段(全局变量),进程之间的通信如果要保证数据安全,需要一定的IPC机制以及数据安全机制
- 调度和切换:由于进程的开启需要较多资源调度,所以进程的上下文的切换相比线程的上下文的切换要慢很多。
- 在多线程的操作系统中,进程只是一个资源分配的单位,并不是操作系统执行的实体
1.3 线程的特点
在多线程的操作系统中,通常是在一个进程中包括多个线程,每个线程都是作为利用CPU的基本单位,是花费最小开销的实体。线程具有以下属性。
- 轻型实体:线程中的实体基本上不拥有系统资源,只是有一点必不可少的、能保证独立运行的资源。线程的实体包括程序、数据和TCB。线程是动态概念,它的动态特性由线程控制块TCB(Thread Control Block)描述。
- 独立调度以及分配的基本单位:在多线程OS中,线程是能独立运行的基本单位,因而也是独立调度和分派的基本单位。由于线程很“轻”,故线程的切换非常迅速且开销小(在同一进程中的)。
- 共享进程资源:程在同一进程中的各个线程,都可以共享该进程所拥有的资源,这首先表现在:所有线程都具有相同的进程id,这意味着,线程可以访问该进程的每一个内存资源;此外,还可以访问进程所拥有的已打开文件、定时器、信号量机构等。由于同一个进程内的线程共享内存和文件,所以线程之间互相通信不必调用内核。
- 可并发操作:在一个进程中的多个线程之间,可以并发执行,甚至允许在一个进程中所有线程都能并发执行;同样,不同进程中的线程也能并发执行,充分利用和发挥了处理机与外围设备并行工作的能力。
补充TCB的内容
TCB包括以下信息: (1)线程状态。 (2)当线程不运行时,被保存的现场资源。 (3)一组执行堆栈。 (4)存放每个线程的局部变量主存区。 (5)访问同一个进程中的主存和其它资源。 用于指示被执行指令序列的程序计数器、保留局部变量、少数状态参数和返回地址等的一组寄存器和堆栈。
1.4 使用线程的使用场景
开启一个字处理软件进程,该进程肯定需要办不止一件事情,比如监听键盘输入,处理文字,定时自动将文字保存到硬盘,这三个任务操作的都是同一块数据,因而不能用多进程。只能在一个进程里并发地开启三个线程,如果是单线程,那就只能是,键盘输入时,不能处理文字和自动保存,自动保存时又不能输入和处理文字。
1.5 内存中的线程
多个线程共享同一个进程的地址空间中的资源,是对一台计算机上多个进程的模拟,有时也称线程为轻量级的进程。
而对一台计算机上多个进程,则共享物理内存、磁盘、打印机等其他物理资源。多线程的运行也多进程的运行类似,是cpu在多个线程之间的快速切换。
不同的进程之间是充满敌意的,彼此是抢占、竞争cpu的关系,如果迅雷会和QQ抢资源。而同一个进程是由一个程序员的程序创建,所以同一进程内的线程是合作关系,一个线程可以访问另外一个线程的内存地址,大家都是共享的,一个线程干死了另外一个线程的内存,那纯属程序员脑子有问题。
类似于进程,每个线程也有自己的堆栈,不同于进程,线程库无法利用时钟中断强制线程让出CPU,可以调用thread_yield运行线程自动放弃cpu,让另外一个线程运行。
线程通常是有益的,但是带来了不小程序设计难度,线程的问题是:
1. 父进程有多个线程,那么开启的子线程是否需要同样多的线程
2. 在同一个进程中,如果一个线程关闭了文件,而另外一个线程正准备往该文件内写内容呢?
因此,在多线程的代码中,需要更多的心思来设计程序的逻辑、保护程序的数据。
2.python中的GIL锁
2.1 python如何使用GIL锁
全局解释器锁GIL
Python代码的执行由Python虚拟机(也叫解释器主循环)来控制。Python在设计之初就考虑到要在主循环中,同时只有一个线程在执行。虽然 Python 解释器中可以“运行”多个线程,但在任意时刻只有一个线程在解释器中运行。
对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同一时刻只有一个线程在运行。
在多线程环境中,Python 虚拟机按以下方式执行:
a、设置 GIL;
b、切换到一个线程去运行;
c、运行指定数量的字节码指令或者线程主动让出控制(可以调用 time.sleep(0));
d、把线程设置为睡眠状态;
e、解锁 GIL;
d、再次重复以上所有步骤。
在调用外部代码(如 C/C++扩展函数)的时候,GIL将会被锁定,直到这个函数结束为止(由于在这期间没有Python的字节码被运行,所以不会做线程切换)编写扩展的程序员可以主动解锁GIL。
python中线程不能同时访问cpu的确是一个缺点,不过我们在写代码的时候可以判断这个代码块是多I/O还是多计算,多I/O即使是单cpu也是可以顺畅执行的,不过对于高计算的则开启多进程然后进程中开启多线程并发的执行计算,对于python还有一个原因是他是解释性语言,在编译的同时不知道下面的代码的具体执行情况,不能多cpu同时调用。
3.线程在python中的使用
3.1线程的俩种方式
方式一:
from threading import Thread import time import os def func(i): time.sleep(1) print('子进程%s!'%i,os.getpid()) for i in range(10): t = Thread(target= func,args=(i,)) t.start() print('主进程!',os.getpid())
方式二:
#自建类的方式 from threading import Thread import time import os class MyThread(Thread): def __init__(self,name): super().__init__() self.name = name def run(self):#和进程一样,自建类,必须用run函数 time.sleep(1) print('子进程%s'%self.name,os.getpid()) if __name__ == '__main__': print('主线程!',os.getpid()) for i in range(10): p = MyThread(i) p.start()
执行代码我们会发现,肉眼所看基本上所有的线程都是一起建立成功,这个时候就会有疑问,不是有GIL全局解释锁吗,不应该每隔一秒才创建一个线程吗,看图
步骤如下
- for循环,创建一个线程
- 线程经过GIL锁,获得一个cpu去执行
- 执行线程中的代码,碰到time.sleep,执行代码后将代码从运行态丢到阻塞队列,归还GIL锁的钥匙
- 线程2创建并重复执行2和3这俩个步骤
- 由于cpu运算速度极快,我们肉眼可以看到十个线程在间隔1秒左右后,基本同时创建了10个线程
注意:程序中的IO操作是不占用全局解释器锁和CPU的
3.2 threading内的其他用法
3.2.1 join方法
线程中join方法和进程中的join方法时一致的,都是对主线程的阻塞,直到子线程的中的代码执行结束
看代码
from threading import Thread import time def func(i): time.sleep(1) print('子线程%s'%i) t_list =[] for i in range(10): t = Thread(target= func,args=(i,)) t_list.append(t) t.start() for t in t_list: t.join() print('所有线程已经全部执行完毕!')
3.2.2 守护线程
守护线程和守护进程是差不多的,只是开启的方式一个是参数一个是内置方法
from threading import Thread import time def main(): print('主线程开始!') time.sleep(3) print('主线程结束') def func(): print('子线程开始') time.sleep(5) print('子线程结束') def daemon(): while True: time.sleep(1) print('我活的很好!') t = Thread(target= daemon,) t.setDaemon(True)#设定为守护线程的方法 t.start()
t1 = Thread(target= main,)
t2 = Thread(target= func,)
t1.start()
t2.start()
看结果会发现,守护线程不紧守护了主线程,同时守护了子线程,可以得出以下结果
守护线程和守护进程不同,守护线程会守护主线程直到主线程结束,如果这个时候主线程要等待子线程的执行结束,那么守护线程同时对子线程进行守护
3.2.3 数据共享
同一个进程内的线程由于是共享一个资源空间,所以一个进程内的线程是天生共享资源,看代码
# from threading import Thread # # n = 100 # def func(): # global n # n-=1 # # t_list =[] # for i in range(100): # t = Thread(target= func,) # t_list.append(t) # t.start() # for i in t_list: # i.join() # print(n)
3.2.4 查看线程id
俩种方式
方式一:
#方式一:面向对象的方式 from threading import Thread import os class MyThread(Thread): def __init__(self,name): super().__init__() self.name = name def run(self): print('子进程%s'%self.name,self.ident,os.getpid()) for i in range(10): t = MyThread(i) t.start()
方式二:
#方式二:普通方式 from threading import Thread,currentThread import os def func(i): print('子进程%s'%i,currentThread().ident,os.getpid()) for i in range(10): t = Thread(target= func,args= (i,)) t.start()
3.2.5 threading的其他方法
from threading import Thread,currentThread,enumerate,active_count import os import time def func(i): time.sleep(1) print('子进程%s'%i,currentThread().ident,os.getpid()) for i in range(10): t = Thread(target= func,args= (i,)) t.start() print(enumerate())#枚举查看活着的线程 print(active_count())#查看活着的线程数量,子线程数量加上主线程
3.3 线程中的锁
3.3.1 互斥锁
虽说一个进程内线程都是共享资源,,但是实际中我们还是在线程中如果有+=、-=、*=、/=等操作还是要加锁,先看例子,为什么会这样
from threading import Thread import time n = 0 def func(): global n count = n time.sleep(0.1)#用阻塞模拟时间片轮转 n = count+1 t_list =[] for i in range(100): t = Thread(target= func) t_list.append(t) t.start() for i in t_list: i.join() print(n) #结果为1 #如果将阻塞的代码注释后就会变成100 #对于操作系统来说,一定数量的线程并不会出现问题,不过在实际使用中为了安全性,建议不要做,需要加锁 #修改后的代码 from threading import Thread,Lock n = 0 def func(lock):#加锁操作 global n with lock: n +=1 t_list =[] lock = Lock() for i in range(100): t = Thread(target= func,args=(lock,)) t_list.append(t) t.start() for i in t_list: i.join() print(n) #结果:100 #这杨不管如何,结果都是100
结论:
- 没有多个线程操作同一个变量的时候 就可以不加锁
- 如果你是执行基础数据类型的内置方法 :lst.append,lst.pop,lst.extand,lst.remove,dic.get['key']都是线程安全的
- 补充:dis模块 可以把你的python代码翻译成cpu指令
3.3.2 迭代锁
3.3.2.1 死锁问题
要说迭代锁首先要说死锁问题,而说明死锁问题就要说一下著名的科学家吃面问题
科学家吃面问题:四个科学家吃面,桌子上只有一盘面和一把叉子,只有在同时拿到面和叉子,才可以迟到,拿到面或者叉子是不能做任何事情,下面模拟下科学家吃面这个问题,代码如下
#死锁版
from threading import Thread,Lock import time nooodle_lock = Lock() fork_lock = Lock() def eat1(name): nooodle_lock.acquire() print('%s 拿到了面条!'%name) fork_lock.acquire() print('%s 拿到了叉子!' % name) print('%s 开始吃面'%name) time.sleep(1)#模拟吃面 fork_lock.release() print('%s 放下了叉子!' % name) nooodle_lock.release() print('%s 放下了面条'% name) def eat2(name): fork_lock.acquire() print('%s 拿到了叉子!' % name) nooodle_lock.acquire() print('%s 拿到了面条!' % name) print('%s 开始吃面'%name) time.sleep(1)#模拟吃面 nooodle_lock.release() print('%s 放下了面条'% name) fork_lock.release() print('%s 放下了叉子!' % name) t1 = Thread(target= eat1,args=('饭桶1',)) t2 = Thread(target= eat2,args=('饭桶2',)) t3 = Thread(target= eat1,args=('饭桶3',)) t4 = Thread(target= eat2,args=('饭桶4',)) t1.start() t2.start() t3.start() t4.start() #结果 饭桶1 拿到了面条! 饭桶1 拿到了叉子! 饭桶1 开始吃面 饭桶1 放下了叉子! 饭桶2 拿到了叉子! 饭桶1 放下了面条 饭桶3 拿到了面条! #饭桶2 和饭桶3 各拿面条和叉子互不放弃,这样就造成了死锁
3.3.2.2 迭代锁
根据上面的例子,我们就需要引入一个迭代锁的概念
正常的互斥锁,就相当于一个门,一把钥匙,谁抢到谁进去,如图
而迭代锁就是一个门,可是门口挂着一串钥匙,拿到这一串钥匙的人就可以进入门后面的门,如图
把上面的科学家吃面问题解决一下
#迭代锁版
from threading import Thread,RLock import time nooodle_lock =fork_lock= RLock() def eat1(name): nooodle_lock.acquire() print('%s 拿到了面条!'%name) fork_lock.acquire() print('%s 拿到了叉子!' % name) print('%s 开始吃面'%name) time.sleep(1)#模拟吃面 fork_lock.release() print('%s 放下了叉子!' % name) nooodle_lock.release() print('%s 放下了面条'% name) def eat2(name): fork_lock.acquire() print('%s 拿到了叉子!' % name) nooodle_lock.acquire() print('%s 拿到了面条!' % name) print('%s 开始吃面'%name) time.sleep(1)#模拟吃面 nooodle_lock.release() print('%s 放下了面条'% name) fork_lock.release() print('%s 放下了叉子!' % name) t1 = Thread(target= eat1,args=('饭桶1',)) t2 = Thread(target= eat2,args=('饭桶2',)) t3 = Thread(target= eat1,args=('饭桶3',)) t4 = Thread(target= eat2,args=('饭桶4',)) t1.start() t2.start() t3.start() t4.start() #结果就是每一个人都可以吃到面了,只是Lock改为RLock
总结:
- 死锁现象:使用了多把锁在一个线程内进行了多次Acquire导致了不可恢复的阻塞。
- 形成原因: # 两个锁 锁了两个资源 我要做某件事 需要同时拿到这两个资源 多个线程同时执行这个步骤。
- 递归锁(rlock)、互斥锁(lock):递归锁是不容易发生死锁现象的,互斥锁的使用不当容易发生死锁,递归锁可以快速的帮我们解决死锁问题。
- 死锁的真正问题不在于互斥锁,而在于对于互斥锁的混乱使用,要想真正的解决死锁问题,还是要找出互斥锁的问题进行修正才能解决根本。
那么按照这个思路,我们可以改变一下上面的科学家吃面问题
#最终版
from threading import Thread,Lock import time lock = Lock() def eat1(name): lock.acquire() print('%s 拿到了面条!'%name) print('%s 拿到了叉子!' % name) print('%s 开始吃面'%name) time.sleep(1)#模拟吃面 print('%s 放下了叉子!' % name) print('%s 放下了面条'% name) lock.release() def eat2(name): lock.acquire() print('%s 拿到了叉子!' % name) print('%s 拿到了面条!' % name) print('%s 开始吃面'%name) time.sleep(1)#模拟吃面 print('%s 放下了面条'% name) print('%s 放下了叉子!' % name) lock.release() Thread(target= eat1,args=('饭桶1',)).start() Thread(target= eat2,args=('饭桶2',)).start() Thread(target= eat1,args=('饭桶3',)).start() Thread(target= eat2,args=('饭桶4',)).start()
最终结论:使用迭代锁的时候,绝大多数是因为代码中各种各样的锁太多,机制混乱,在一头雾水的时候可以先用带带锁解决问题,然后在想着一点点的去解决问题!
小练习:基于多线程的socket的套接字,简易版
#server端 import socket from threading import Thread sk = socket.socket() sk.bind(('127.0.0.1',8500)) sk.listen() def talk(conn,addr): print(addr) while True: conn.send(b'hello world!') while True: conn,addr = sk.accept() Thread(target=talk,args=(conn,addr)).start()#将具体聊天的过程交给不同的线程去执行 #客户端 import socket sk = socket.socket() sk.connect(('127.0.0.1',8500)) while True: msg = sk.recv(1024) print(msg) sk.close() #最后的结果就是服务器端可以接收多个客户端连接,每个客户端可以不受影响的去接收
3.4 线程队列
首先说一下,一个进程中的多线程共用一块资源空间,为什么还需要用到队列,主要是有俩个方面
- 多个线程之间维持一个数据先后的秩序
- 线程模块的队列是线程之间数据安全的
不同于多进程中的有专门的队列模块,线程中使用队列就需要调用python中自带模块queue模块
3.4.1 线程队列中常用的方法
#线程队列中的常用方法 1.队列的实例化: q = queue.Queue(num)#num可以为空,加上则设定队列的大小 2.队列的放入以及取出: q.put() q.get() #他俩都是阻塞事件 3.队列状态的判断 q.size() q.empty() q.full() 在多进程中状态经常有误,很少去使用 4.队列的快速取出以及快速放入 import queue q = queue.Queue(1) q.put(1) try: print(q.put_nowait('abc'))#快速放入,不管队列是都满,如果满则丢弃,所以经常不用,队列满会报queue.Full错误 except queue.Full: pass try: print(q.get_nowait())#快速取出,不管队列是否空,空则报错queue.Empty except queue.Empty: pass
3.4.2 算法中的常用数据类型
在算法中有以下常用数据类型
- 队列:先进先出FIFO
- 栈:后进先出LIFO,主要是用在算法上
- 堆:三角形的数据类型,越重要的数据在越在上面
- 树:关系型的数据类型
3.4.3 其他补充队列
第一种:栈,后进先出
import queue q = queue.LifoQueue()#栈 q.put(1) q.put(2) q.put(3) print(q.get()) print(q.get()) print(q.get()) #结果 3 2 1
第二种:优先级队列,put的时候是一个元祖,按照元祖第一个元素的ASCII码的位置
import queue q = queue.PriorityQueue() q.put((2,'abc')) q.put((1,'bbb')) q.put((3,'ccc')) print(q.get()) print(q.get()) print(q.get()) #结果 (1, 'bbb') (2, 'abc') (3, 'ccc')
3.5 线程池
线程在最开始创建的时候是没有线程池的概念的,所以线程与进程不同的是没有专门的线程池的方法,需要调用其他模块
Python标准模块--concurrent.futures,官方参考介绍文章
https://docs.python.org/dev/library/concurrent.futures.html
concurrent.futures模块常用方法
#1 介绍 concurrent.futures模块提供了高度封装的异步调用接口 ThreadPoolExecutor:线程池,提供异步调用 ProcessPoolExecutor: 进程池,提供异步调用 Both implement the same interface, which is defined by the abstract Executor class. #2 基本方法 #submit(fn, *args, **kwargs) 异步提交任务 #map(func, *iterables, timeout=None, chunksize=1) 取代for循环submit的操作 #shutdown(wait=True) 相当于进程池的pool.close()+pool.join()操作 wait=True,等待池内所有任务执行完毕回收完资源后才继续 wait=False,立即返回,并不会等待池内的任务执行完毕 但不管wait参数为何值,整个程序都会等到所有任务执行完毕 submit和map必须在shutdown之前 #result(timeout=None) 取得结果 #add_done_callback(fn) 回调函数
3.5.1 线程模块
#线程模块 #1.普通建池 from threading import currentThread from concurrent.futures import ThreadPoolExecutor import time def func(i): time.sleep(1) print('子线程%s'%i,currentThread().ident)#查看线程名,以及线程id tp = ThreadPoolExecutor(4)#建池 for i in range(20): tp.submit(func,i)#就相当于进程池中的apply_async异步的提交任务 tp.shutdown()#相当于进程池中的.close()与.join() #2.map建池 from threading import currentThread from concurrent.futures import ThreadPoolExecutor import time def func(i): time.sleep(1) print('子线程%s'%i,currentThread().ident)#查看线程名,以及线程id tp = ThreadPoolExecutor(4)#建池 tp.map(func,range(20)) #3.获取返回值 from threading import currentThread from concurrent.futures import ThreadPoolExecutor import time def func(i): time.sleep(1) print('子线程%s'%i,currentThread().ident)#查看线程名,以及线程id return i**2 tp = ThreadPoolExecutor(4)#建池 ret_l =[] for i in range(20): ret = tp.submit(func,i)#就相当于进程池中的apply_async异步的提交任务 ret_l.append(ret) tp.shutdown()#相当于进程池中的.close()与.join() for i in ret_l: print(i.result())#就相当于进程中的结果的get() #4.回调函数 from threading import currentThread from concurrent.futures import ThreadPoolExecutor import time def func(i): time.sleep(1) print('子线程%s'%i,currentThread().ident)#查看线程名,以及线程id return i**2 def call_back(ret): print(ret.result())#ret只是获取到结果的对象,需要对对象进行result() tp = ThreadPoolExecutor(4)#建池 for i in range(20): tp.submit(func,i).add_done_callback(call_back)#就相当于进程池中的apply_async异步的提交任务,和进程池中的callback=函数名一致 tp.shutdown()#相当于进程池中的.close()与.join()
3.5.2 进程模块
#进程模块 #1.普通建池 from concurrent.futures import ProcessPoolExecutor import os import time def func(i): time.sleep(1) print('子进程%s'%i,os.getpid()) if __name__ == '__main__': pp= ProcessPoolExecutor(4) for i in range(20): pp.submit(func,i) pp.shutdown() #2.map建池 from concurrent.futures import ProcessPoolExecutor import os import time def func(i): time.sleep(1) print('子进程%s'%i,os.getpid()) if __name__ == '__main__': pp= ProcessPoolExecutor(4) pp.map(func,range(20)) #3.返回值 from concurrent.futures import ProcessPoolExecutor import os import time def func(i): time.sleep(1) print('子进程%s'%i,os.getpid()) return i**2 if __name__ == '__main__': pp= ProcessPoolExecutor(4) ret_l =[] for i in range(20): ret = pp.submit(func,i) ret_l.append(ret) pp.shutdown() for ret in ret_l: print(ret.result()) #4.回调函数 from concurrent.futures import ProcessPoolExecutor import os import time def func(i): time.sleep(1) print('子进程%s'%i,os.getpid()) return i**2 def call_back(ret): print(ret.result()) if __name__ == '__main__': pp= ProcessPoolExecutor(4) for i in range(20): pp.submit(func,i).add_done_callback(call_back) pp.shutdown()
最后:后期我们建池的话主要使用concurrent.futures,一是比较新,二是既可建线程池也可以建立进程池,方便使用!
总结:
- 进程池:定义的进程数(cpu数量/cpu数量+1)
- 线程池:定义的线程数(cpu数*5)