摘要:Python
,多线程
,线程同步
,线程池
,GIL
线程概述
当一个进程里面只有一个线程时,叫做单线程
,超过一个线程就叫做多线程,在多线程中会有一个主线程
来完成整个进程从开始到结束的全部操作,而其他的线程会在主线程的运行过程中被创建或退出。
线程的创建和原理
(1)线程的模块
Python的线程模块主要是threading
模块
(2)主线程的产生
一个Python程序就是一个进程,每个进程会默认启动一个线程,即主线程,可以通过threading模块中的current_thread
函数查看
import threading
print(threading.current_thread())
<_MainThread(MainThread, started 140486979041088)>
current_thread返回的是当前线程的信息,默认是进程下的主线程,结果尖括号的第一个显示他是主线程_MainThread
,圆括号中的MainThread
是线程名称,started
后面的是线程号,在操作系统中每一个线程都会有一个ID号,用为唯一标识。
threading模块中还有两个常用的函数,分别是
- threading.enumerate:返回
正在运行的线程list
,正在运行指的是线程处于启动后结束前的状态 - threading.activeCount:返回
正在运行的线程数量
,相当于len(threading.enumerate())
print(threading.enumerate())
print(threading.active_count())
在Python console中存在4个线程,分别打印出线程列表和线程数如下
[<_MainThread(MainThread, started 140486979041088)>, , , ]
4
Python中所有进程的主线程,名称都是一样的叫做MainThread
,而子线程的名字需要在创建时指定,如果不指定Python会默认起名字。
(3)创建子线程
创建子线程有两种方法,都是通过threading.Thread
类来实现
- 直接对类threading.Thread进行实例化,实例化的时候指定执行的函数和入参,然后调用实例化的线程对象的start方法创建线程
- 用threading.Thread派生出一个新的子类,并且实例化该子类,重载run方法,调用start方法创建线程
先来看第一种方法直接实例化
import threading
def handle(sid):
print("Thread {} run, info: {}".format(sid, threading.current_thread()))
for i in range(10):
t = threading.Thread(target=handle, args=(i, ))
t.start() # 这个地方加t.join()是一样的,默认不守护进程,则主线程会等待子线程执行完毕再关闭
print(threading.current_thread())
执行效果如下,此时线程类型变为Thread
,并且分别以数字ID命名,和主线程MainThread
不一样,在一个新的线程是执行handle函数,此时函数内部的threading.current_thread()返回的就是当前线程的信息
Thread 0 run, info:
Thread 1 run, info:
Thread 2 run, info:
Thread 3 run, info:
Thread 4 run, info:
Thread 5 run, info:
Thread 6 run, info:
Thread 7 run, info:
Thread 8 run, info:
Thread 9 run, info:
<_MainThread(MainThread, started 140591187232576)>
Process finished with exit code 0
可以改变线程的名称,比如修改这一行代码
t = threading.Thread(target=handle, name="a" + str(i), args=(i, ))
输出如下
Thread 0 run, info:
Thread 1 run, info:
...
再看一下使用线程类,新建一个类对象继承threading.Thread,然后重写run方法
,在调用start的时候线程对象会调用run方法
import threading
def handle(sid):
print("Thread {} run, info: {}".format(sid, threading.current_thread()))
class MyClass(threading.Thread):
def __init__(self, sid):
threading.Thread.__init__(self) # 重写__init__方法,等同于super().__init__()
self.sid = sid
def run(self): # 在子类中如果方法与父类相同,父类的方法被覆盖失效
handle(self.sid)
for i in range(10):
t = MyClass(i)
t.start()
查看原类的run方法,可见如果不重写run函数,默认会执行传入的target参数
def run(self):
try:
if self._target:
self._target(*self._args, **self._kwargs)
finally:
del self._target, self._args, self._kwargs
返回输出如下
Thread 0 run, info:
Thread 1 run, info:
Thread 2 run, info:
Thread 3 run, info:
Thread 4 run, info:
Thread 5 run, info:
Thread 6 run, info:
Thread 7 run, info:
Thread 8 run, info:
Thread 9 run, info:
Process finished with exit code 0
除此之外在实例化类对象的时候还有一个参数daemon
,默认是False,每个线程的守护进程参数和主线程一致,默认是False就是说进程退出时必须等待这个线程也退出,看一下源码
if daemon is not None:
self._daemonic = daemon
else:
self._daemonic = current_thread().daemon # False
总结一下线程的创建,目的就是创建线程并且将执行函数绑定到线程上,有两种方法
- 在实例化时为target赋值参数
- 继承threading.Thread类,重写run方法,并在run中指定执行函数
(4)threadingThread类的方法
- run:线程活动的方法
- start:启动线程活动
- join:该方法有一个可选参数timeout,主线程一直处于阻塞状态,除非当前调用join的线程执行完毕,或者达到超时时间,主线程执行完自己的任务以后,就退出了,主线程一旦执行结束,则全部线程全部被终止执行
(5)线程内部状态及原理
线程状态分为5种:创建,就绪,运行,阻塞,退出,过程如下
- 创建:在完成threading.Thread实例化之后,就完成了线程的创建
- 就绪:调用start函数,线程就进入就绪状态,等待CPU分配时间片
- 运行:当线程被分配到时间片,线程就进入运行状态,执行run函数
- 阻塞:在执行run函数期间,线程可以被打断,进入阻塞状态,阻塞状态结束又回到就绪状态,接着运行
- 退出:线程运行结束进入退出状态
互斥锁
多线程的优势在于并发,即可以同时运行多个任务,但是当多线程需要共享数据时,也会带来数据不同步的问题,互斥锁就是解决数据不同步的问题。
(1)多线程的问题
以一个例子来看,所有线程共享一个全局变量,并且在执行函数之后修改这个全局变量,但是执行时间不同
import time
import threading
a = 1
def handle(sid):
global a
a = a * 2
time.sleep(sid % 2)
print(sid, a)
class MyClass(threading.Thread):
def __init__(self, sid):
super().__init__()
self.sid = sid
def run(self):
handle(self.sid)
threads = []
for i in range(10):
t = MyClass(i)
t.start()
for t in threads:
t.join() # 主线程等待所有其他子线程执行完毕
输出结果如下,可见单数的id由于需要等待1s导致在sleep的时候其他线程还在更改共享数据,1,3,5,7四个线程输出的值都市9号线程的结果
0 2
2 8
4 32
6 128
8 512
1 1024
3 1024
7 1024
5 1024
9 1024
上述代码使用了threads列表join
使得主线程必须等待子线程全部执行完毕再退出,如果在每一个start后面直接执行join,输出结果完全不一样
import time
import threading
a = 1
def handle(sid):
global a
a = a * 2
time.sleep(sid % 2)
print(sid, a)
class MyClass(threading.Thread):
def __init__(self, sid):
super().__init__()
self.sid = sid
def run(self):
handle(self.sid)
for i in range(10):
t = MyClass(i)
t.start()
t.join()
原因是如果在每个线程start后直接join,则主线程被每个子线程的start后阻塞,无法启动循环列表后面的子线程,相当于单线程
0 2
1 4
2 8
3 16
4 32
5 64
6 128
7 256
8 512
9 1024
(2)互斥所
锁的出现就是解决多线程之间的同步问题,其核心在于将执行程序中的某段代码保护起来(相当于锁起来),被锁起来的代码一次只能允许一个线程执行。在Python中使用threading.RLock
类来创建锁,他有两个方法acquire
,release
- acquire:获得锁,acquire之后的代码只允许一个线程执行
- release:释放锁,release之后的代码又可以多线程交叉执行
import time
import threading
lock = threading.RLock()
a = 1
def handle(sid):
lock.acquire()
global a
a = a * 2
time.sleep(sid % 2)
print(sid, a)
lock.release()
class MyClass(threading.Thread):
def __init__(self, sid):
super().__init__()
self.sid = sid
def run(self):
handle(self.sid)
threads = []
for i in range(10):
t = MyClass(i)
t.start()
for t in threads:
t.join()
输出结果如下,此时线程序号和乘数的值顺序对应上了
0 2
1 4
2 8
3 16
4 32
5 64
6 128
7 256
8 512
9 1024
锁的注意事项:
- 锁的作用是将多线程变回单线程,牺牲性能换取准确性
- 在代码设计中应尽量避免使用锁,即使使用了锁也要让被锁住的代码区域尽可能小
- 有加锁的操作一定要有解锁的操作,否则代码就是去多线程的优势
信号量
信号量(semaphore)是一种带计数的线程同步机制,当调用release时,增加计数,当acquire时,减少计数,当计数为0时,自动阻塞
,等待release被调用,可以实现并发限制,分为纯粹的信号量(Semaphore)和带有不按揭的信号量(BoundedSemaphore),区别如下
- Semaphore:在调用release函数时,单纯将计数器+1,不会检查+1之后计数器是否超过上限
- BoundedSemaphore:在调用release函数时,会检查计数器+1后是否超过上限,对计数器的上限制进行校验,是更加安全的机制
在创建信号量时需要指定信号量的个数,没调用一个acquire时信号量减少一个,当信号量为0时就是说同一个时间线程数量超过信号量个数时,主线程阻塞等待信号量被释放恢复
,直接看代码
import threading
import datetime
import time
semaphore = threading.Semaphore(3)
def foo():
semaphore.acquire()
time.sleep(5)
print("当前时间:", datetime.datetime.today().strftime("%Y-%m-%d %H:%M:%S"))
semaphore.release()
class MyClass(threading.Thread):
def __init__(self):
super(MyClass, self).__init__()
def run(self):
foo()
threads = []
for i in range(10):
t = MyClass()
t.start()
threads.append(t)
for t in threads:
t.join()
上述代码设置信号量为3,开启10个线程,执行打印时间的函数,10个线程超出3个信号量限制,因此每当线程数量达到3个主线程阻塞,在循环内部卡住使得下面的子线程无法启动,最后的记过是没3个线程一批打印输出时间,一批和一批时间之间间隔5秒
当前时间: 2021-05-14 11:18:52
当前时间: 2021-05-14 11:18:52
当前时间: 2021-05-14 11:18:52
当前时间: 2021-05-14 11:18:57
当前时间: 2021-05-14 11:18:57
当前时间: 2021-05-14 11:18:57
当前时间: 2021-05-14 11:19:02
当前时间: 2021-05-14 11:19:02
当前时间: 2021-05-14 11:19:02
当前时间: 2021-05-14 11:19:07
再看BoundedSemaphore,如果调用多次release超出信号量上限就会报错,但是Semaphore不会报错,修改代码如下
semaphore = threading.BoundedSemaphore(3)
def foo():
semaphore.acquire()
time.sleep(5)
print("当前时间:", datetime.datetime.today().strftime("%Y-%m-%d %H:%M:%S"))
semaphore.release()
semaphore.release()
输出报错如下,显示信号释放太多次
ValueError: Semaphore released too many times
使用线程池提升运行效率
线程池是一种多线程处理方式,是在正常的多线程处理方式上的一种优化
- 正常线程使用方式是:创建,启动,结束,销毁
- 线程池吃力方式是:在程序启动时就创建好若干个线程,并保存到内存中,当线程启动并执行完成后并不做销毁处理,而是等待下次再使用,需要用时过来取,用完了还回去
在需要频繁创建线程的系统中,一般都会使用线程池技术,原因是
- 每个线程的创建都需要占用系统资源,是一件相对耗时的事情,销毁线程时还需要回收线程资源,线程池技术可以省去创建与回收过程中所浪费的系统开销
- 在某些系统中需要为每个子任务创建线程,容易导致线程数量失控,直到程序崩溃,线程池技术可以很好的固定线程的数量
(1)实现线程池
Python中使用concurrent.features
模块下的ThreadPoolExecutor
来实现线程池,只需要传入线程个数系统就能为该线程池初始化相应个数的线程,线程的使用有两种
- 抢占式:线程池中线程的执行顺序不固定,该方式使用ThreadPoolExecutor下的submit方法实现
- 非抢占式:线程将按照调用的顺序执行,此方式使用ThreadPoolExecutor的map方法来实现
从使用角度来看,抢占式更灵活,非抢占式更严格。
- 抢占式:允许线程池中线程执行函数不一样,并且某个线程异常不影响其他线程
- 非抢占式:要求线程池中的线程必须执行同样的处理函数,并且一旦其中一个线程异常,其他线程也会停止
(2)单线程和多线程处理时间比较
先写一个简单的程序,使用单线程循环遍历执行一个函数
import time
person = ["Anna", "Gary", "All"]
def print_person(p):
print(p)
time.sleep(2)
t1 = time.time()
for p in person:
print_person(p)
t2 = time.time()
print("耗时:", t2 - t1)
输出如下,耗时6s
Anna
Gary
All
耗时: 6.005347490310669
下一步实现抢占式线程池,使用with关键字创建线程池,将列表元素一个一个传入执行函数,调用实例化对象的submit
方法将线程启动,代码如下
import time
from concurrent.futures import ThreadPoolExecutor
person = ["Anna", "Gary", "All"]
def print_person(p):
print(p)
time.sleep(2)
t1 = time.time()
with ThreadPoolExecutor(3) as executor: # 使用with上下文
for p in person:
executor.submit(print_person, p)
t2 = time.time()
print("耗时:", t2 - t1)
输出如下,可见多线程的并发缩短了程序的运行时间
Anna
Gary
All
耗时: 2.002558708190918
进一步实现非抢占式线程池,也是使用with关键字,他是使用实例化线程池的map
方法启动线程,并且传入函数参数时直接传入列表
t1 = time.time()
with ThreadPoolExecutor(3) as executor:
executor.map(print_person, person)
t2 = time.time()
print("耗时:", t2 - t1)
输出如下,也是2s,和抢占式效率差不多
Anna
Gary
All
耗时: 2.0014290809631348
一个业务案例,使用多线程读取es数据,通过prov分组多任务并发进行
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from elasticsearch import Elasticsearch
LOGGRR = logging.getLogger("thread_test")
LOGGRR.setLevel(logging.INFO)
streamhandler = logging.StreamHandler()
streamhandler.setLevel(logging.INFO)
streamhandler.setFormatter(logging.Formatter('%(asctime)s [%(module)s] %(levelname)s [%(lineno)d] %(message)s', '%Y-%m-%d %H:%M:%S'))
LOGGRR.addHandler(streamhandler)
es = Elasticsearch("xxx.xx.x.xxx:xxxx")
def get_prov_list():
body = {
"size": 0,
"aggs": {
"name": {
"terms": {
"field": "prov",
"size": 100
}
}
}
}
query = es.search(index="index_name", body=body)
return [x["key"] for x in query['aggregations']['name']['buckets']]
def get_prov_info(prov):
body = {
"query": {
"bool": {
"must": [
{"term": {
"prov": prov
}},
{"term": {
"data_status": "Y"
}},
]
}
},
"_source": ["user_name", "filed"]
}
res = []
cnt = 0
query = es.search(index="index_name", body=body, scroll="5m", size=3000)
total = query['hits']['total']
scroll_id = query["_scroll_id"]
scroll_res = query['hits']['hits']
res.extend(scroll_res)
cnt += len(scroll_res)
LOGGRR.info("-------------{}: {} / {}".format(prov, cnt, total))
for i in range(0, int(total / 1000) + 1):
scroll_res = es.scroll(scroll_id=scroll_id, scroll="5m")['hits']['hits']
res.extend(scroll_res)
cnt += len(scroll_res)
LOGGRR.info("-------------{}: {} / {}".format(prov, cnt, total))
return res
if __name__ == '__main__':
prov_list = get_prov_list()
res = []
with ThreadPoolExecutor() as pool:
futures = [pool.submit(get_prov_info, prov) for prov in prov_list]
pool.shutdown(wait=True)
for fut in futures:
print(len(fut.result()))
res.extend(fut.result())
print(len(res))
多线程读全量es 44秒,单线程230秒。
多线程和GIL
(1)知识准备
GIL 是Python的全局解释器锁
,同一进程中假如有多个线程运行,一个线程在运行Python程序的时候会霸占Python解释器,使该进程内的其他线程无法运行。在GIL中,全局锁并不是一直锁定的,比如当线程遇到IO等待或ticks计数(Python3改为计时器,执行时间达到阈值后,当前线程释放GIL)达到100,cpu会做切换,把cpu的时间片让给其他线程执行,此时GIL释放,释放时候所有线程继续进行锁竞争,Python里一个进程永远只能同时执行一个线程
- 在计算密集型操作时,GIL的存在导致多线程无法很好的立即多核CPU的并发处理能力,原因是这种情况下ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以Python下的多线程对CPU密集型代码并不友好
- 在IO密集操作线程中,比如在网络通信,网络爬虫,time.sleep()延时的时候,将释放GIL,达到并发能力,原因是单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率
- 多进程能够充分利用CPU达到并行,原因是每个进程有各自独立的GIL,互不干扰,这样就可以真正意义上的并行执行,所以在Python中,多进程的执行效率优于多线程
(2)IO密集型和CPU密集型多线程测试
以爬取网页进行解析进行测试,线程池最大线程数8个,爬去50次,总共需要1.4秒
import re
import time
import requests
from concurrent.futures import ThreadPoolExecutor
headers = {
...
}
def handle(sid):
response = requests.get("https://movie.douban.com/top250", headers=headers)
res = re.findall(r'alt="(.*?)"', response.text, re.S)
print(str(sid) + ",".join(res))
sid_list = list(range(50))
t1 = time.time()
with ThreadPoolExecutor(8) as executor:
executor.map(handle, sid_list)
t2 = time.time()
print("耗时:", t2 - t1) # 1.496328353881836
单线程模式测试如下需要40s,可见多线程应对IO密集型可以实现并发提高效率
t1 = time.time()
for i in range(50):
handle(i)
t2 = time.time()
print("耗时:", t2 - t1) # 40s
再测试一下CPU密集型,此处以geopy计算球面距离为例,这个计算包含多个三角函数的计算,耗时1.5s
import time
from concurrent.futures import ThreadPoolExecutor
from geopy.distance import great_circle
def handle(sid):
for i in range(1000):
res = great_circle((41.49008, -71.312796), (41.499498, -81.695391)).meters
print(str(sid) + str(res))
sid_list = list(range(100))
t1 = time.time()
with ThreadPoolExecutor(8) as executor:
executor.map(handle, sid_list)
t2 = time.time()
print("耗时:", t2 - t1) # 1.5076820850372314
再测一下单线程,竟然比多线程耗时低,可见在CPU密集型中线程频繁切换反而多线程效率更低
t1 = time.time()
for i in range(100):
handle(i)
t2 = time.time()
print("耗时:", t2 - t1) # 1.1195