多线程基本原理
为什么计算机可以做到这么多软件同时运行?
多线程的含义
要说多线程,就要先说线程,
要说线程,就要先说进程
进程 可以理解是一个可以独立运行的程序单位
- 打开一个浏览器,这就开启了一个浏览器进程
- 打开一个文本编辑器,这就开启了一个文本编辑器进程
但一个进程中是可以同时处理很多事情
-
比如在浏览器中,我们可以在多个选项卡中打开多个页面
有的页面在播放音乐,有的页面在播放视频,他们可以同时运行,互不干扰
为什么能同时运行这么多任务?这就需要引出线程的概念了
这一个个的任务,实际上就对应着一个个线程的执行
而进程呢?它就是线程的集合,进程就是由一个或多个线程构成
线程是操作系统进行运算调度的最小单位,是进程中的一个最小运行单位
浏览器进程中,播放音乐就是一个线程,播放视频也是一个线程
其中还有很多其他的线程在同时运行,这些线程的并发或并行执行最后使得浏览器可以同时运行这么多任务
并发和并行
一个程序在计算机中运行,其底层是处理器通过运行一条条的指令来实现的
并发(concurrency)
它是指同一时刻只能由一条指令执行
但是多个线程的对应指令被快速轮换地执行
- 一个处理器,他先执行线程A的指令一段时间,再执行线程B的指令一段时间,再切回到线程A执行一段时间
由于处理器执行指令的速度和切换的速度非常快,人完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这使得宏观上看起来多个线程在同时运行,但微观上只是这个处理器在连续不断地在多个线程之间切换和执行,每个线程的执行一定会占用这个处理器一个时间片段,同一时刻,只有一个线程在执行
并行(parallel)
它是指同一时刻,有多条指令在多个处理器上同时执行
并行必须要依赖于多个处理器。不论是从宏观上还是微观上,多个线程都是在同一时刻一起执行的
并行只能在多处理器系统中存在,如果我们的计算机处理器只有一个核,那就不可能实现并行
并发在单处理器和多处理器系统中都是可以存在的,仅靠一个核,就可以实现并发
-
比如系统处理器需要同时运行多个线程
如果系统处理器只有一个核,那它只能通过并发的方式来运行这些线程
如果系统处理器有多个核,当一个核在执行一个线程时,另一个核也可以执行另一个线程,这样两个线程就实现了并行执行
其他的线程也可能和另外的线程处在同一个核上执行,它们之间就是并发执行
具体的执行方式,就取决于操作系统的调度了
多线程适用场景
在一个程序进程中,有一些操作是比较耗时或者需要等待的
比如等待数据库的查询结果的返回,等待网页结果的响应
如果使用单线程,处理器必须要等到这些操作完成之后才能继续往下执行其他操作,而这个线程在等待的过程中,处理器明显是可以来执行其他的操作的
如果使用多线程,处理器就可以在某个线程等待的时候,去执行其他的线程,从而从整体上提高效率
线程在执行过程中很多情况下是需要等待的
网络爬虫就是一个非常典型的例子,爬虫在向服务器发起请求之后,有一段时间必须要等待服务器的响应返回,这种任务就属于IO密集型任务 ,对于这种任务,如果我们启用多线程,处理器就可以在某个线程等待的过程中去处理其他的任务,从而提高整体的爬取效率
但并不是所有的任务都是IO密集型任务,还有一种任务叫做计算密集型任务(CPU密集型任务),也就是说任务的运行一直需要处理器的参与。
此时如果我们开启了多线程,一个处理器从一个计算密集型任务切换到另一个计算密集型任务上去,处理器依然不会停下来,始终会忙于计算,这样并不会节省总体的时间,因为需要处理的任务的计算总量是不变的。如果线程数目过多,反而还会在线程切换中多耗费一些时间,整体效率会变低
因此如果任务不全是计算密集型任务,我们可以使用多线程来提高程序整体的执行效率。尤其对于网络爬虫这种IO密集型任务来说,使用多线程会大大提高程序整体的爬取效率
python实现多线程
在python中,实现多线程的模块叫作threading 是python自带的模块
Thread 直接创建子线程
我们可以使用Thread类来创建一个线程
创建时需要指定target参数为运行的方法名称
如果被调用的方法需要传入额外的参数
则通过Thread的args参数来制定
import threading
import time
def target(second):
print(f'Threading {threading.current_thread().name} is running')
print(f'Threading {threading.current_thread().name} sleep {second}s')
time.sleep(second)
print(f'Threading {threading.current_thread().name} is ended')
print(f'Threading {threading.current_thread().name} is running')
for i in [1, 5]:
thread = threading.Thread(target=target, args=[i])
thread.start()
print(f'Threading {threading.current_thread().name} is ended')
Threading MainThread is running
Threading Thread-1 is running
Threading Thread-1 sleep 1s
Threading Thread-2 is running
Threading Thread-2 sleep 5s
Threading MainThread is ended
Threading Thread-1 is ended
Threading Thread-2 is ended
我们首先声明了一个方法,target,它接受一个参数为second,
通过方法的实现可以发现, 这个方法执行了一个time.sleep休眠操作,second参数就是休眠秒数
线程的名字通过threading.current_thread().name 来获取
- 如果是主线程的话,其值就是MainThread
- 如果是子线程的话,其值就是Thread-*
然后我们通过Thead类新建了两个线程,target参数就是我们定义的方法名,args以列表的形式传递
两次循环中,这里i分别为1 和5,这样两个线程就分别休眠1秒和5秒,声明完成之后,我们调用start方法即可开始线程的运行
观察结果可以发现,这里一共产生了三个线程,分别是主线程MainThread和两个子线程Thread-1、Thread-2
主线程首先运行结束,紧接着Thread-1、Thread-2 才接连运行结束,分别间隔了1秒、4秒。这说明主线程并没有等待子线程运行完毕才结束运行,而是直接退出了,有点不符合常理
如果我们想要主线程等待子线程运行完毕之后才退出,可以让每个子线程对象都调用下join方法
threads = []
for i in [1, 5]:
thread = threading.Thread(target=target, args=[i])
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
Threading MainThread is running
Threading Thread-1 is running
Threading Thread-1 sleep 1s
Threading Thread-2 is running
Threading Thread-2 sleep 5s
Threading Thread-1 is ended
Threading Thread-2 is ended
Threading MainThread is ended
这样,主线程必须等待子线程都运行结束,主线程才继续运行并结束
继承Thread类创建子线程
通过继承Thread类的方式创建一个线程,该线程需要执行的方法写在类的run方法里面即可
import threading
import time
class MyThread(threading.Thread):
def __init__(self,second):
threading.Thread.__init__(self)
self.second = second
def run(self):
print(f'Threading {threading.current_thread().name} is running')
print(f'Threading {threading.current_thread().name} sleep {self.second}s')
time.sleep(self.second)
print(f'Threading {threading.current_thread().name} is ended')
print(f'Threading {threading.current_thread().name} is running')
threads = []
for i in [1, 5]:
thread = MyThread(i)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
print(f'Threading {threading.current_thread().name} is ended')
两种实现方式,其运行效果是相同的
守护线程
如果一个线程被设置为守护线程,那么意味着这个线程是“不重要的”
这意味着如果主线程结束了而该守护线程还没有运行完,那么它将会被强制结束
python中我们可以通过setDaemon方法来将某个线程设置为守护线程
import threading
import time
def target(second):
print(f'Threading {threading.current_thread().name} is running')
print(f'Threading {threading.current_thread().name} sleep {second}s')
time.sleep(second)
print(f'Threading {threading.current_thread().name} is ended')
print(f'Threading {threading.current_thread().name} is running')
t1 = threading.Thread(target=target, args=[2])
t1.start()
t2 = threading.Thread(target=target,args=[5])
t2.setDaemon(True)
t2.start()
print(f'Threading {threading.current_thread().name} is ended')
通过setDaemon方法将t2设置为了守护线程
这样主线程在运行完毕时,t2线程会随着线程的结束而结束
Threading MainThread is running
Threading Thread-1 is running
Threading Thread-1 sleep 2s
Threading Thread-2 is running
Threading Thread-2 sleep 5s
Threading MainThread is ended
Threading Thread-1 is ended
我们没有看到Thread-2打印退出的消息,Thread-2随着主线程的退出而退出了
这里并没有调用join方法,如果我们让t1和t2都调用join方法,主线程就会仍然等待各个子线程执行完毕再退出,不论其是否守护线程
互斥锁
在一个进程中的多个线程是共享资源的
比如在一个进程中,有一个全局变量count用来计数,现在我们声明多个线程,每个线程运行时都给count加1,
import threading
import time
count = 0
class MyThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
def run(self):
global count
temp = count + 1
time.sleep(0.001)
count = temp
threads = []
for _ in range(1000):
thread = MyThread()
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
print(f'Final count: {count}')
我们声明了1000个线程,每个线程都是现取到当前的全局变量count值,然后休眠一小段时间,然后对count赋予新的值
Final count: 69
最后的结果居然只有69,而且多次运行或者换个环境运行结果是不同的
因为count这个值是共享的,每个线程都可以执行temp = count 这行代码时拿到当前count的值,但是这些线程中的一些线程可能是并发或者并行执行的,这就导致不同的线程拿到可能是同一个count值,最后导致有些线程的count的加1操作并没有生效,导致最后的结果偏小
因此,如果多个线程同时对某个数据进行读取或修改,就会出现不可预料的结果。为了避免这种情况,我们需要对多个线程进行同步,要实现同步,我们可以对需要操作的数据进行加锁保护,要用到threading.Lock了
加锁保护是什么意思?就是说,某个线程在对数据进行操作前,需要先加锁,这样其他的线程发现被加锁了之后,就无法继续向下执行,会一直等待锁被释放,只有加锁的线程把锁释放了,其他的线程才能继续加锁并对数据做修改,修改完了再释放锁。
这样就可以确保同一时间只有一个线程操作数据,多个线程不会再同时读取和修改同一个数据,这样最后的运行结果就是对的了
import threading
import time
count = 0
class MyThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
def run(self):
global count
lock.acquire()
temp = count + 1
time.sleep(0.001)
count = temp
lock.release()
lock = threading.Lock()
threads = []
for _ in range(1000):
thread = MyThread()
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
print(f'Final count: {count}')
Final count: 1000
关于theading更多的方法,如信号量、队列等
https://docs.python.org/zh-cn/3.7/library/threading.html#module-threading
python多线程的问题
由于Python中GIL的限制,导致不论是在单核还是多核条件下,在同一时刻只能运行一个线程,导致python多线程无法发挥多核并行的优势
GIL全称为Global Interpreter Lock 全局解释器锁
其最初设计是出于数据安全而考虑的
在python多线程下,每个线程的执行方式如下
- 获取GIL
- 执行对应线程的代码
- 释放GIL
可见,某个线程想要执行,必须先拿到GIL,我们可以把GIL看做是通行证,并且在一个python进程中,GIL只有一个。
拿不到通行证的线程,就不允许执行。
这样就会导致,即使是多核条件下,一个python进程下的多个线程,同一时刻也只能执行一个线程
不过对于爬虫这种IO密集型任务来说,这个问题影响并不大。而对于计算密集型任务来说,由于GIL的存在,多线程总体的运行效率相比可能反而比单线程更低