并发编程之多线程
一 线程理论
1 什么是线程
在传统操作系统中,每个进程有一个地址空间,而且默认就有一个控制线程线程顾名思义,就是一条流水线工作的过程,一条流水线必须属于一个车间,一个车间的工作过程是一个进程
车间负责把资源整合到一起,是一个资源单位,而一个车间内至少有一个流水线
流水线的工作需要电源,电源就相当于cpu
所以,进程只是用来把资源集中到一起(进程只是一个资源单位,或者说资源集合),而线程才是cpu上的执行单位。
多线程(即多个控制线程)的概念是,在一个进程中存在多个控制线程,多个控制线程共享该进程的地址空间,相当于一个车间内有多条流水线,都共用一个车间的资源。
例如,北京地铁与上海地铁是不同的进程,而北京地铁里的13号线是一个线程,北京地铁所有的线路共享北京地铁所有的资源,比如所有的乘客可以被所有线路拉。
2 线程的创建开销小
创建进程的开销要远大于线程?
如果我们的软件是一个工厂,该工厂有多条流水线,流水线工作需要电源,电源只有一个即cpu(单核cpu)
一个车间就是一个进程,一个车间至少一条流水线(一个进程至少一个线程)
创建一个进程,就是创建一个车间(申请空间,在该空间内建至少一条流水线)
而建线程,就只是在一个车间内造一条流水线,无需申请空间,所以创建开销小
进程之间是竞争关系,线程之间是协作关系?
车间直接是竞争/抢电源的关系,竞争(不同的进程直接是竞争关系,是不同的程序员写的程序运行的,迅雷抢占其他进程的网速,360把其他进程当做病毒干死)
一个车间的不同流水线式协同工作的关系(同一个进程的线程之间是合作关系,是同一个程序写的程序内开启动,迅雷内的线程是合作关系,不会自己干自己)
3 线程与进程的区别
线程共享创建它的进程的地址空间;进程有自己的地址空间。
线程可以直接访问其进程的数据段;进程有自己的父进程的数据段副本。
线程可以直接与其进程的其他线程通信;进程必须使用进程间通信来与同级进程通信。
新线程很容易创建;新进程需要父进程的复制。
线程可以对同一进程的线程进行相当大的控制;进程只能对子进程进行控制。
对主线程的更改(取消、优先级更改等)可能会影响进程其他线程的行为;对父进程的更改不会影响子进程。
4 为何要用多线程
多线程指的是,在一个进程中开启多个线程,简单的讲:如果多个任务共用一块地址空间,那么必须在一个进程内开启多个线程。详细的讲分为4点:
-
多线程共享一个进程的地址空间
-
线程比进程更轻量级,线程比进程更容易创建可撤销,在许多操作系统中,创建一个线程比创建一个进程要快10-100倍,在有大量线程需要动态和快速修改时,这一特性很有用
-
若多个线程都是cpu密集型的,那么并不能获得性能上的增强,但是如果存在大量的计算和大量的I/O处理,拥有多个线程允许这些活动彼此重叠运行,从而会加快程序执行的速度。
-
在多cpu系统中,为了最大限度的利用多核,可以开启多个线程,比开进程开销要小的多。(这一条并不适用于python)
5 多线程应用举例
开启一个字处理软件进程,该进程肯定需要办不止一件事情,比如监听键盘输入,处理文字,定时自动将文字保存到硬盘,这三个任务操作的都是同一块数据,因而不能用多进程。只能在一个进程里并发地开启三个线程,如果是单线程,那就只能是,键盘输入时,不能处理文字和自动保存,自动保存时又不能输入和处理文字。
6 在内核空间实现的线程
内核级线程:切换由内核控制,当线程进行切换的时候,由用户态转化为内核态。切换完毕要从内核态返回用户态;可以很好的利用smp,即利用多核cpu。windows线程就是这样的。
7 用户级与内核级线程的对比
一: 以下是用户级线程和内核级线程的区别:
- 内核支持线程是OS内核可感知的,而用户级线程是OS内核不可感知的。
- 用户级线程的创建、撤消和调度不需要OS内核的支持,是在语言(如Java)这一级处理的;而内核支持线程的创建、撤消和调度都需OS内核提供支持,而且与进程的创建、撤消和调度大体是相同的。
- 用户级线程执行系统调用指令时将导致其所属进程被中断,而内核支持线程执行系统调用指令时,只导致该线程被中断。
- 在只有用户级线程的系统内,CPU调度还是以进程为单位,处于运行状态的进程中的多个线程,由用户程序控制线程的轮换运行;在有内核支持线程的系统内,CPU调度则以线程为单位,由OS的线程调度程序负责线程的调度。
- 用户级线程的程序实体是运行在用户态下的程序,而内核支持线程的程序实体则是可以运行在任何状态下的程序。
二: 内核线程的优缺点
优点:
- 当有多个处理机时,一个进程的多个线程可以同时执行。
缺点:
- 由内核进行调度。
三: 用户进程的优缺点
优点:
- 线程的调度不需要内核直接参与,控制简单。
- 可以在不支持线程的操作系统中实现。
- 创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多。
- 允许每个进程定制自己的调度算法,线程管理比较灵活。
- 线程能够利用的表空间和堆栈空间比内核级线程多。
- 同一进程中只能同时有一个线程在运行,如果有一个线程使用了系统调用而阻塞,那么整个进程都会被挂起。另外,页面失效也会产生同样的问题。
缺点:
- 资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用
二 开启线程的两种方式
# 第一种
from threading import Thread
import time
def tsak():
print('线程开始...')
time.sleep(1)
print('线程结束...')
if __name__ == '__main__':
p=Thread(target=tsak,)
p.start()
print('主...')
# 线程轻量级,传输太快
# 第二种
from threading import Thread
import time
class Task(Thread):
def run(self) -> None:
print('线程开始...')
time.sleep(1)
print('线程结束...')
if __name__ == '__main__':
t = Task()
t.run()
print('主...')
三 线程的相关方法
Thread实例对象的方法
# isAlive(): 返回线程是否活动的。
# getName(): 返回线程名。
# setName(): 设置线程名。
threading模块提供的一些方法:
# threading.currentThread(): 返回当前的线程变量。
# threading.enumerate(): 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
# threading.activeCount(): 返回正在运行的线程数量,与len(threading.enumerate())有相同的结果。
主线程等待子线程结束
from threading import Thread
import time
def sayhi(name):
time.sleep(2)
print('%s say hello' %name)
if __name__ == '__main__':
t=Thread(target=sayhi,args=('egon',))
t.start()
t.join()
print('主线程')
print(t.is_alive())
'''
egon say hello
主线程
False
'''
四 守护线程
无论是进程还是线程,都遵循:守护xxx会等待主xxx运行完毕后被销毁
需要强调的是:运行完毕并非终止运行
#1.对主进程来说,运行完毕指的是主进程代码运行完毕
#2.对主线程来说,运行完毕指的是主线程所在的进程内所有非守护线程统统运行完毕,主线程才算运行完毕
详细解释:
#1 主进程在其代码结束后就已经算运行完毕了(守护进程在此时就被回收),然后主进程会一直等非守护的子进程都运行完毕后回收子进程的资源(否则会产生僵尸进程),才会结束,
#2 主线程在其他非守护线程运行完毕后才算运行完毕(守护线程在此时就被回收)。因为主线程的结束意味着进程的结束,进程整体
from threading import Thread
import time
def sayhi(name):
time.sleep(2)
print('%s say hello' %name)
if __name__ == '__main__':
t=Thread(target=sayhi,args=('egon',))
t.setDaemon(True) #必须在t.start()之前设置
t.start()
print('主线程')
print(t.is_alive())
'''
主线程
True
'''
五 GIL锁
python
#1 python的解释器有很多,cpython,jpython,pypy(python写的解释器)
#2 python的库多,库都是基于cpython写起来的,其他解释器没有那么多的库
#3 cpython中有一个全局大锁,每条线程要执行,必须获取到这个锁
#4 为什么会有这个锁呢?python的垃圾回收机制
#5 python的多线程其实就是单线程
#6 某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,
并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行
# 7 总结:cpython解释器中有一个全局锁(GIL),线程必须获取到GIL才能执行,
我们开的多线程,不管有几个cpu,同一时刻,只有一个线程在执行
多进程下的多线程除外
(python的多线程,不能利用多核优势)
# 8 如果是io密集型操作:开多线程
# 9如果是计算密集型:开多进程
以上两句话,只针对与cpython解释器
六 同步锁
三个需要注意的点:
#1.线程抢的是GIL锁,GIL锁相当于执行权限,拿到执行权限后才能拿到互斥锁Lock,其他线程也可以抢到GIL,但如果发现Lock仍然没有被释放则阻塞,即便是拿到执行权限GIL也要立刻交出来
#2.join是等待所有,即整体串行,而锁只是锁住修改共享数据的部分,即部分串行,要想保证数据安全的根本原理在于让并发变成串行,join与互斥锁都可以实现,毫无疑问,互斥锁的部分串行效率要更高
#3. 一定要看本小节最后的GIL与互斥锁的经典分析
GIL VS Lock
机智的同学可能会问到这个问题,就是既然你之前说过了,Python已经有一个GIL来保证同一时间只能有一个线程来执行了,为什么这里还需要lock?
首先我们需要达成共识:锁的目的是为了保护共享的数据,同一时间只能有一个线程来修改共享的数据
然后,我们可以得出结论:保护不同的数据就应该加不同的锁。
最后,问题就很明朗了,GIL 与Lock是两把锁,保护的数据不一样,前者是解释器级别的(当然保护的就是解释器级别的数据,比如垃圾回收的数据),后者是保护用户自己开发的应用程序的数据,很明显GIL不负责这件事,只能用户自定义加锁处理,即Lock
过程分析:所有线程抢的是GIL锁,或者说所有线程抢的是执行权限
线程1抢到GIL锁,拿到执行权限,开始执行,然后加了一把Lock,还没有执行完毕,即线程1还未释放Lock,有可能线程2抢到GIL锁,开始执行,执行过程中发现Lock还没有被线程1释放,于是线程2进入阻塞,被夺走执行权限,有可能线程1拿到GIL,然后正常执行到释放Lock。。。这就导致了串行运行的效果
GIL锁是解释器层面的锁,保证了解释器层面的数据安全,但是不能利用多核的优势,GIL锁从可以并行或并发变的只能串行。这样避免了并行时,多个线程同时更改一个内存数据造成数据错乱的结果,比如一条线程绑定变量关系,一条线程进行垃圾回收,同时进行就有可能存在垃圾回收了还没绑定变量关系的数据。
GIL锁相当于在解释器层面加了一把互斥锁。
而在应用程序的层面,所有线程抢GIL锁获得执行权限,但是此时GIL锁不能保证数据安全,比如并发遇到了IO会造成数据的不安全,于是就有了互斥锁,让线程即使遇到了IO也不会让出cpu,而是继续执行,从而把并行转换成了串行。
# 只有GIL锁没有普通互斥锁
from threading import Thread
import time
money = 100
def task():
global money
time.sleep(1)
money -= 1
if __name__ == '__main__':
lis = []
for i in range(10):
t = Thread(target=task)
t.start()
lis.append(t)
print(money) # 此时打印的是遇到IO之前的money
for t in lis:
t.join()
print(money) # 数据发生混乱,由于并发读取数据并修改
# 有普通互斥锁
from threading import Thread, Lock
import time
money = 100
matex = Lock()
def task():
global money
matex.acquire()
time.sleep(1)
money -= 1
matex.release()
if __name__ == '__main__':
lis = []
for i in range(10):
t = Thread(target=task)
t.start()
lis.append(t)
print(money) # 此时打印的是遇到IO之前的money
# 先执行完子线程的在运行主线程
for t in lis:
t.join()
print('-----')
print(money) # 在互斥锁内的运行成了串行,每一个线程要上锁时其他进程不能运行这一段内容要等待解锁
print(money)
七 死锁现象与递归锁
所谓死锁: 是指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程,如下就是死锁
from threading import Thread, Lock
import time
mutexA = Lock()
mutexB = Lock()
def eat_apple(name):
mutexA.acquire()
print(f'{name}获得了a锁')
mutexB.acquire()
print(f'{name}获得了b锁')
print('开始吃苹果并且吃完了')
mutexB.release()
print(f'{name}释放了b锁')
mutexA.release()
print(f'{name}释放了a锁')
def eat_egg(name):
mutexB.acquire()
print(f'{name}获得了b锁')
time.sleep(2)
# 碰到IO就是下一个线程来执行然后获得了a锁,并且在b锁这里卡住
mutexA.acquire()
print(f'{name}获得了a锁')
print('开始吃鸡蛋并且吃完了')
mutexA.release()
print(f'{name}释放了a锁')
mutexB.release()
print(f'{name}释放了b锁')
if __name__ == '__main__':
lis = ['egon', 'justin', 'arther', 'tank']
for name in lis:
t1 = Thread(target=eat_apple, args=(name,))
t2 = Thread(target=eat_egg, args=(name,))
t1.start()
t2.start()
# 死锁现场,egon与justin同时都需要获取两个锁才能运行
# 但是egon拿了A锁,等B锁,justin拿了B锁,等A锁,两者互相等对方释放自己需要的锁,但是两者由于等锁都暂停了
解决方法,递归锁,在Python中为了支持在同一线程中多次请求同一资源,python提供了可重入锁RLock。
这个RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。上面的例子如果使用RLock代替Lock,则不会发生死锁:
from threading import Thread, RLock
import time
# 递归锁(可重入),同一个线程可以多次acquire,每acquire一次,内部计数器增加一次,
# 每relaese一次,内部计数器减少一次,这样就解决死锁问题,因为死锁的是因为有两把锁,然后两个线程互相占着一把,
# 递归锁只有一把且可在线程内重复,从根部解决这个问题,但是只有计数为零时,其他线程才能引入递归锁
# 这样就不会出现互相等对方将占用的锁释放的情况
mutexA = RLock()
mutexB = mutexA
def eat_apple(name):
mutexA.acquire()
print(f'{name}获得了a锁')
mutexB.acquire()
print(f'{name}获得了b锁')
print('开始吃苹果并且吃完了')
mutexB.release()
print(f'{name}释放了b锁')
mutexA.release()
print(f'{name}释放了a锁')
def eat_egg(name):
mutexB.acquire()
print(f'{name}获得了b锁')
time.sleep(2) # 遇到了IO,转到下个线程去执行 ,但计数不为零其他人都获取不到这把锁,
# 于是转回上一个线程,将锁释放完
mutexA.acquire()
print(f'{name}获得了a锁')
print('开始吃鸡蛋并且吃完了')
mutexA.release()
print(f'{name}释放了a锁')
mutexB.release()
print(f'{name}释放了b锁')
if __name__ == '__main__':
lis = ['egon', 'justin', 'arther', 'tank']
for name in lis:
t1 = Thread(target=eat_apple, args=(name,))
t2 = Thread(target=eat_egg, args=(name,))
t1.start()
t2.start()
八 信号量
同进程的一样
Semaphore管理一个内置的计数器,
每当调用acquire()时内置计数器-1;
调用release() 时内置计数器+1;
计数器不能小于0;当计数器为0时,acquire()将阻塞线程直到其他线程调用release()。
实例:(同时只有5个线程可以获得semaphore,即可以限制最大连接数为5):
from threading import Thread,Semaphore
import threading
import time
# def func():
# if sm.acquire():
# print (threading.currentThread().getName() + ' get semaphore')
# time.sleep(2)
# sm.release()
def func():
sm.acquire()
print('%s get sm' %threading.current_thread().getName())
time.sleep(3)
sm.release()
if __name__ == '__main__':
sm=Semaphore(5)
for i in range(23):
t=Thread(target=func)
t.start()
九 Event
同进程的一样
线程的一个关键特性是每个线程都是独立运行且状态不可预测。如果程序中的其 他线程需要通过判断某个线程的状态来确定自己下一步的操作,这时线程同步问题就会变得非常棘手。为了解决这些问题,我们需要使用threading库中的Event对象。 对象包含一个可由线程设置的信号标志,它允许线程等待某些事件的发生。在 初始情况下,Event对象中的信号标志被设置为假。如果有线程等待一个Event对象, 而这个Event对象的标志为假,那么这个线程将会被一直阻塞直至该标志为真。一个线程如果将一个Event对象的信号标志设置为真,它将唤醒所有等待这个Event对象的线程。如果一个线程等待一个已经被设置为真的Event对象,那么它将忽略这个事件, 继续执行
event.isSet():返回event的状态值;
event.wait():如果 event.isSet()==False将阻塞线程;
event.set(): 设置event的状态值为True,所有阻塞池的线程激活进入就绪状态, 等待操作系统调度;
event.clear():恢复event的状态值为False。
# 一些线程需要等到其他线程执行完成之后才能执行,类似于发射信号
# 比如一个线程等待另一个线程执行结束再继续执行
from threading import Thread, Event, Lock
import time
event = Event()
mutex = Lock()
def girl(name):
print(f'{name}现在不单身,恋爱ing')
time.sleep(5)
print(f'{name}分手了,给屌丝男发信号')
event.set()
def boy(name):
print(f'{name}等待女孩分手')
mutex.acquire()
event.wait()
time.sleep(0.1)
print(f'女孩分手了,冲')
mutex.release()
if __name__ == '__main__':
lyf = Thread(target=girl, args=('如花',))
lyf.start()
for i in range(10):
b = Thread(target=boy, args=(f'屌丝男{i}号',))
b.start()
# 作业:读取文件,一个线程读取前一半发信号给另一个线程让它去读取后一半
from threading import Thread, Event
import os
event = Event()
def read_first():
data = os.path.getsize('a.txt') // 2
with open('a.txt', 'r', encoding='utf-8')as f:
print(f.read(data))
print('前半部分已读出')
event.set()
def read_last():
event.wait()
print('接收到了我开始读了')
data = os.path.getsize('a.txt') // 2
with open('a.txt', 'r', encoding='utf-8')as f:
f.seek(data, 0)
print(f.read())
print('后半部分已读出')
if __name__ == '__main__':
r1 = Thread(target=read_first)
r2 = Thread(target=read_last)
r1.start()
r2.start()
十 线程queue
# 进程queue和线程不是一个
# 线程queue
from queue import Queue, LifoQueue, PriorityQueue
# 线程间通信,因为共享变量会出现数据不安全问题,用线程queue通信,不需要加锁,
# 内部自带,queue是线程安全的
'''
三种线程Queue
-Queue:队列,先进先出
-PriorityQueue:优先级队列,谁小谁先出
-LifoQueue:栈,后进后出
'''
# 如何使用
# q=Queue(5)
# q.put("lqz")
# q.put("egon")
# q.put("铁蛋")
# q.put("钢弹")
# q.put("金蛋")
#
#
# # q.put("银蛋")
# # q.put_nowait("银蛋")
# # 取值
# print(q.get())
# print(q.get())
# print(q.get())
# print(q.get())
# print(q.get())
# # 卡住
# # print(q.get())
# # q.get_nowait()
# # 是否满,是否空
# print(q.full())
# print(q.empty())
# LifoQueue,后进后出
q=LifoQueue(5)
q.put("lqz")
q.put("egon")
q.put("铁蛋")
q.put("钢弹")
q.put("金蛋")
#
# q.put("ddd蛋")
print(q.get())
#PriorityQueue:数字越小,级别越高
# q=PriorityQueue(3)
# q.put((-10,'金蛋'))
# q.put((100,'银蛋'))
# q.put((101,'铁蛋'))
# q.put((1010,'铁dd蛋')) # 不能再放了
#
# print(q.get())
# print(q.get())
# print(q.get())
十一 Python标准模块--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)
回调函数
map用法
from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
import os,time,random
def task(n):
print('%s is runing' %os.getpid())
time.sleep(random.randint(1,3))
return n**2
if __name__ == '__main__':
executor=ThreadPoolExecutor(max_workers=3)
# for i in range(11):
# future=executor.submit(task,i)
executor.map(task,range(1,12)) #map取代了for+submit
回调函数
from concurrent.futures import ThreadPoolExecutor,ProcessPoolExecutor
from multiprocessing import Pool
import requests
import json
import os
def get_page(url):
print('<进程%s> get %s' %(os.getpid(),url))
respone=requests.get(url)
if respone.status_code == 200:
return {'url':url,'text':respone.text}
def parse_page(res):
res=res.result()
print('<进程%s> parse %s' %(os.getpid(),res['url']))
parse_res='url:<%s> size:[%s]\n' %(res['url'],len(res['text']))
with open('db.txt','a') as f:
f.write(parse_res)
if __name__ == '__main__':
urls=[
'https://www.baidu.com',
'https://www.python.org',
'https://www.openstack.org',
'https://help.github.com/',
'http://www.sina.com.cn/'
]
# p=Pool(3)
# for url in urls:
# p.apply_async(get_page,args=(url,),callback=pasrse_page)
# p.close()
# p.join()
p=ProcessPoolExecutor(3)
for url in urls:
p.submit(get_page,url).add_done_callback(parse_page) #parse_page拿到的是一个future对象obj,需要用obj.result()拿到结果
十二 IO密集型与计算密集型
'''
---以下只针对于cpython解释器
-在单核情况下:
-开多线程还是开多进程
不管干什么最终都要开线程,因为线程是CPU调度的最小单位
-在多核情况下:
-如果是计算密集型,需要开进程,能被多个cpu调度执行
-如果IO密集型,需要开线程,CPU遇到IO会切换到其他线程执行,线程更轻量级,切换IO速度更快且资源更小
'''
from threading import Thread
from multiprocessing import Process
import time
# 计算密集型
def task():
count = 0
for i in range(10000000):
count += 1
if __name__ == '__main__':
ctime = time.time()
lis = []
for i in range(10):
t = Thread(target=task) # 开线程:6.038380861282349 即使多核每次也只有一个线程去执行计算任务
# t=Process(target=task) #开进程:4.292168140411377 可以多核开多个进程然后每个进程调用线程去执行计算任务
t.start()
lis.append(t)
for j in lis:
j.join()
# 以免时间过长cpu选择挂起去执行主线程或主进程
print(time.time() - ctime)
# IO密集型
def task():
time.sleep(2)
if __name__ == '__main__':
ctime = time.time()
lis = []
for i in range(100):
# t = Thread(target=task) # 开线程:2.0329859256744385 线程的IO,由于线程更轻量级,堵塞-运行,切换的速度更快
t = Process(target=task) # 开进程:5.154810190200806 进程的IO,涉及多方资源的调配,堵塞-就绪-运行,切换速度较慢
t.start()
lis.append(t)
for j in lis:
j.join()
# 以免时间过长cpu选择挂起去执行主线程或主进程
print(time.time() - ctime)