十步杀一人,千里不留行;事了拂衣去,深藏功与名。
《侠客行》 --李白
前言
匠:今天主要介绍 python 的多线程。。。
猪头(慌忙打开电脑):薛微等下我。
电脑:垃圾文件过多,你的电脑正负重前行。。。
猪头(一脸憨笑):容我杀杀毒,清清垃圾。说着打开360安全管家,点击木马查杀和垃圾清理。
匠(眼珠子一转):故事就从360安全管家开始吧。。。
匠:你在操作系统中打开了360安全管家这个程序,也相当于运行了一个任务,而我们通常也将每一个在运行着的程序叫做一个进程,也就是说,进程是应用程序的执行实例。
猪头(眉头微锁,略有所思):嗯~
匠:你在360安全管家执行的木马查杀和垃圾清理就相当于是两个线程,所以,线程是基于且依赖于进程,即一个进程可以有多个线程,这是包含关系;而进程又依赖于操作系统向 CPU 申请空间。
猪头(看着自己的360安全管家,突然灵机一动):所以我电脑上的着两个线程是在同时运行着,这也就是多线程提高速率的原因吗?
匠:非也非也,猪公子所言谬矣!这俩线程乃并发执行,而非并行执行。
猪头(一头雾水):啥是并发?啥事并行啊?
匠:并行指的是俩线程同时运行,这在 python 中是不可能实现的,因为 Cpython(python的一个常用解释器)有 GIL (全局解释器锁)的作用,使得同一时刻只能执行一个线程,所以说这俩线程是并发执行,而并发指的是多线程间交替使用着进程的资源(一旦其中一个线程阻塞挂起就会释放资源给另一个线程使用),由于 CPU 上下文切换速度快,导致可以近似于 "同时" 运行。
猪(挠挠头):信息量有点大。。。
匠:咳咳,接下来要吹一些比较专业性的东西,脑子不够用就记笔记。
匠:线程有时被称为轻量进程(Lightweight Process, LWP),是程序执行流的最小单元。一个标准的线程由线程 ID ,当前指令指针(PC),寄存器集合和堆栈组成。另外,线程是进程的一个实体,是被系统独立调度和分配的基本单位,线程自己不拥有系统资源,只拥有一丁点在运行中必不可少的资源,但它可与同一进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤销另一个线程,同一个进程中多个线程可以并发(并发是 CPU 经过上下文快速切换,并不是同时执行)执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。
猪头:懵了,记不住了。
匠:线程的基本状态:新建,就绪,运行,阻塞。(阻塞完之后就跳转到就绪)
就绪状态是指线程具备运行的所有条件,逻辑上可运行,在等待处理时机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个时间(如某信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。
猪头:。。。
匠:多线程的优点如下:
使用线程可以把占据长时间的程序中的任务放到后台去处理。
用户界面可以更加吸引人,比如用户点击了一个按钮去触发某些事件的处理,可以弹出一个进度条来显示处理的进度。
程序的运行速度可能加快。
在一个等待的任务实现上如用户的输入、文件读写和网络收发数据等,线程就有优势了。在这种情况下我们可以释放一些珍贵的资源和内存占用等等。
猪头(恐惧到了尽头就是愤怒):我学尼玛
创建线程
实践是检验真理的唯一标准,先实操,理论性的东西回头可以再看看。
在 python 中和线程相关的有两个模块,分别是 _thread 和 threading。
_thread:此模块仅提供了低级别的、原始的线程支持,以及一个简单的锁。功能比较有限。
threading:提供了功能丰富的多线程支持, 大多数情况下都用它。
python 主要通过两种方式来创建线程:
使用 threading 模块中 Thread 类的构造器创建线程。即直接对类 threading.Thread 进行实例化创建线程,并调用实例化对象的 start() 方法启动线程。
继承 threading 模块中的 Thread 类创建线程类。即用 threading.Thread 派生出一个新的子类,将新建类实例化创建线程,并调用其 start() 方法启动线程。
方法1:
import threading# 定义线程要调用的方法,*add可接收多个以非关键字方式传入的参数def action(*add): for arc in add: # 调用 getName() 方法获取当前执行该程序的线程名 print(threading.current_thread().getName() + ": " + arc)#定义为线程方法传入的参数my_tuple = ("http://www.hacker.com", "http://www.zhutou.com", "http://www.ai_li.com")#创建线程t = threading.Thread(target=action, args=my_tuple)t.start() # 启动线程
默认情况下,主线程的名字为 MainThread,用户启动的多个线程的名字依次为 Thread-1、Thread-2、Thread-3、...、Thread-n 等。
方法2:继承 Thread 类创建线程类。
需要注意的是,在创建 Thread 子类的时候,必须重写从父类继承得到的 run() 方法。因为该方法即为要创建的子线程执行的方法,其功能相当于第一种创建方法中的 action 自定义函数。
import threading# 创建子线程类,继承自 Thread 类class my_Thread(threading.Thread): def __init__(self, add): threading.Thread.__init__(self) self.add = add # 重写run()方法 def run(self): for arc in self.add: print(threading.current_thread().getName() + " " + arc)my_tuple = ("http://www.hacker.com", "http://www.zhutou.com", "http://www.ai_li.com")mythread = my_Thread(my_tuple)mythread.start()# 主线程执行此循环for i in range(5): print(threading.current_thread().getName())
可以看到,当前程序中有 2 个线程,分别为主线程 MainThread 和子线程 Thread-1,它们以并发方式执行,即 Thread-1 执行一段时间,然后 MainThread 执行一段时间。
通过轮流获得 CPU 执行一段时间的方式,程序的执行在多个线程之间切换,从而给用户一种错觉,即多个线程似乎同时在执行。
数据共享
先看个栗子:
import threading# 全局变量n = 0# 定义线程调用的函数def task(): global n for i in range(100): n += 1 print('----> task 中的n值是:', n)if __name__ == '__main__': thread_list = [] for i in range(2): t = threading.Thread(target=task) # 创建线程 thread_list.append(t) for t in thread_list: t.start() # 启动线程 for t in thread_list: t.join() # 阻塞当前的进程,直到调用join方法的那个进程执行完毕,也就是说主进程得等子进程执行完毕后才行
结果正常
将函数迭代次数增大。
import threadingn = 0def task(): global n for i in range(1000000): # 增大迭代次数 n += 1 print('----> task 中的n值是:', n)if __name__ == '__main__': thread_list = [] for i in range(2): t = threading.Thread(target=task) # 创建线程 thread_list.append(t) for t in thread_list: t.start() # 启动线程 for t in thread_list: t.join() # 阻塞当前的进程,直到调用join方法的那个进程执行完毕,也就是说主进程得等子进程执行完毕后才行
结果明显不对。什么原因呢???
原来是 GIL 的错。这里先粗略地说一说 GIL。
GIL(全局解释器锁),很多人都会认为 GIL 是 python 的一个 BUG,其实不然,python 仅仅是一门解释型语言,GIL 的存在,以及GIL 所造的孽关 python 甚事,python 表示很委屈!!!
那应该关谁的事?准确地来说应该关 Cpython 的事。众所周知,python 的解释器有 Cpython,Jpython,PyPy 等,而我们平时所用的就是 Cpython 解释器,也偏偏就只有在 Cpython 解释器上存在 GIL。所以说 GIL 和 Cpython 解释器有瓜,和 python 本身无瓜。
当程序新建了线程,并且调用了解释器的时候程序默认会给每一个线程加上一把 GIL。GIL 设计的初衷是为了保证线程安全,也就是当多个线程共享数据的时候能够保证线程并发执行(同一时刻只能执行一个),不会因为多个线程因抢夺 CPU 资源而造成数据混乱,造成不安全的问题。但是 GIL 也有自动释放的时候:
当前线程遇到了IO时释放。
当前线程执行时间超过设定值时释放。
上面的情况就是线程执行时间超过设定值时释放 GIL,所以导致 Cpython 的内存管理是线程不安全。那应该怎么解决呢?
多线程的优势在于可以同时运行多个任务(实际不是,是伪线程。但至少感觉是这样),然而当线程需要共享数据时,可能存在数据不同步的问题,为了避免这种问题,我们引入了锁的概念。
加锁和死锁
如果多个线程共同对某个数据修改,则可能出现不可预料的结果,为了保证数据的正确性,需要对多个线程进行同步。
这里又引入新的概念:同步和异步。同步与异步说的是任务的提交或执行方式
同步:发起任务后,进入等待状态,必须等到任务执行完成后才能继续执行。
异步:发起任务后,不需要等待,可以执行其他的操作。
同步会有等待的效果但是这和阻塞是完全不同的,阻塞时程序会被剥夺cpu执行权,而同步调用则不会!
在 python 中,使用 threading 模块中的 Lock 或 Rlock 类可以实现简单的线程同步,这俩类都有 acquire 和 release 方法,对于那些需要每次只允许一个线程操作的数据,可以将其操作放到acquire和release方法之间。
from threading import Thread, Locklock = Lock() # 创建一个锁对象n = 0def task(): global n lock.acquire() # 获得锁,锁只有一把,下一个线程只有等锁释放了才能获得,所以只能先阻塞着。 for i in range(1000000): n += 1 lock.release() # 释放锁 print('----> task 中的n值是:', n)if __name__ == '__main__': thread_list = [] for i in range(2): t = Thread(target=task) # 创建线程 thread_list.append(t) for t in thread_list: t.start() # 启动线程 for t in thread_list: t.join() # 阻塞当前的进程,直到调用join方法的那个进程执行完毕,也就是说主进程得等子进程执行完毕后才行
执行正常
但是有锁也不意味着万无一失,开发过程中使用线程,在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源,就会造成死锁。
尽管死锁很少发生,但一旦发生就会造成应用的停止响应,程序不做任何事情。
来举个栗子:
from threading import Thread, Lockimport time# 创建锁对象lockA = Lock() lockB = Lock()class MyThread1(Thread): # 重写 run 方法 def run(self): if lockA.acquire(): # 如果可以获取到锁则返回True print(self.name + '获得了A锁') time.sleep(0.1) # 暂停0.1s,即令线程进入阻塞状态,当达到 sleep 函数规定的时间之后,线程进入就绪状态 if lockB.acquire(): print(self.name + '又获得了B锁') lockB.release() lockA.release()class MyThread2(Thread): def run(self): if lockB.acquire(): print(self.name + '获得了B锁') time.sleep(0.1) # 暂停0.1s,即令线程进入阻塞状态,当达到 sleep 函数规定的时间之后,线程进入就绪状态 if lockA.acquire(): print(self.name + '又获得了A锁') lockA.release() lockB.release()if __name__ == '__main__': t1 = MyThread1() t2 = MyThread2() t1.start() t2.start()
由于俩线程在互相等待着对方释放资源,导致程序不能执行下一步操作。
解决方案:
重构代码
使用timeout参数
重构代码这方面...... 重构是不可能重构的 这辈子不可能重构的,高级的代码又不会写,就是使用 timeout 这种东西,才能维持得了程序顺畅这样子。
那就用 timeout 试试看。
from threading import Thread, Lockimport timelockA = Lock()lockB = Lock()class MyThread1(Thread): def run(self): if lockA.acquire(): print(self.name + '获得了A锁') time.sleep(0.1) # 暂停0.1s,即令线程进入阻塞状态,当达到 sleep 函数规定的时间之后,线程进入就绪状态 if lockB.acquire(timeout=3): print(self.name + '又获得了B锁') lockB.release() lockA.release()class MyThread2(Thread): def run(self): if lockB.acquire(): print(self.name + '获得了B锁') time.sleep(0.1) # 暂停0.1s,即令线程进入阻塞状态,当达到 sleep 函数规定的时间之后,线程进入就绪状态 if lockA.acquire(timeout=3): # 如果阻塞,超时超过 timeout 设定时间,就绕过 if,释放A锁 print(self.name + '又获得了A锁') lockA.release() lockB.release()if __name__ == '__main__': t1 = MyThread1() t2 = MyThread2() t1.start() t2.start()
在俩线程都互相等待对方释放线程的时候,t1 线程的 timeout 到时了,就释放了 A 锁,t2 线程得到 A 锁之后打印消息,退出程序。
线程间通信
生产者与消费者:两个线程之间的通信
Python 的 queue 模块中提供了同步的、线程安全的队列类,包括 FIFO(先入先出)队列Queue,LIFO(后入先出)队列 LifoQueue,和优先级队列PriorityQueue。这些队列都实现了锁原理(可以理解为原子操作,即要么不做,要么就做完),能够在多线程中直接使用。可以使用队列来实现线程间的同步。
举个栗子:
import threadingimport queueimport randomimport time# 生产者:向队列插入数据def produce(q): i = 0 while i < 10: num = random.randint(1, 100) q.put("生产者生产数据:%d" % num) print("生产者生产数据:%d" % num) time.sleep(1) i += 1 q.put(None) q.task_done() # 完成任务# 消费者,向队列取出数据def consume(q): while True: item = q.get() if item is None: break print("消费者获取到:%s" % item) time.sleep(4) q.task_done()if __name__ == '__main__': q = queue.Queue(10) # 创建一个容量为10的队列 # 创建生产者 th = threading.Thread(target=produce, args=(q,)) th.start() # 创建消费者 tc = threading.Thread(target=consume, args=(q,)) tc.start() th.join() tc.join() print("END")
由于俩线程 sleep 的时间不同,所以出现以上情况,但是也不是 t1 插入了4个数据,t2 才取出一个,如果俩线程是就绪状态,具体什么时候给哪个线程分配资源,这个全看 CPU 的资源调度,所以没有一个准确的说法。
再来看一个栗子:
# -*- coding: utf-8 -*-"""@Run by : Python3@Author: ai_li@Date: 2020/7/11 11:29@File: tmp.py"""import threadingimport queuedef GetData(q): while not q.empty(): print(q.get())if __name__ == '__main__': q = queue.Queue(10) port_list = [21, 22, 23, 25, 80, 443, 445, 3306, 3389, 8080] for p in port_list: q.put(p) # 创建5个线程 for i in range(5): threading.Thread(target=GetData, args=(q,)).start()
这个大家可以自己体会一下,在后面写端口扫描器创建线程的时候也会用到这种方法,简单方便,而不用根据线程数和端口数去运算得出每一个线程要处理多少个端口,比较麻烦。
尾声
好了,这篇文章就写到这,仅仅是一篇稍微基础的科普和实操性的文章,扩展性的知识推荐大家可以了解了解 GIL(全局解释器锁),因为它影响着 python 多线程的效率。(正因为有它的存在,才让 python 的多线程出道以来一直被黑,被骂 "伪娘",哦不,是伪线程)
我是匠心,一个在清流旁默默磨剑的匠人,希望有一天能利刃出鞘,仗剑走江湖。
点亮 ,你就能心想事成