上一篇我们讲到,进程是一个相对独立的单元。而线程则是一个进程内单一顺序的控制流,是操作系统运行调度的最小单元。因此,一个进程可以包含多个线程。比如,播放视频时,画面和声音就是不同的线程在处理。
1.创建线程
(1)使用threading.Thread()直接创建
def fun1():print('任务1开始')time.sleep(2)print('任务1结束')def fun2():print('任务2开始')time.sleep(4)print('任务2结束')thread1 = threading.Thread(target=fun1)
thread2 = threading.Thread(target=fun2)# 守护线程,随主线程结束而结束,即不会打印“任务2结束”
thread2.setDaemon(True)# 启动任务
thread1.start()
thread2.start()# 主线程会等待本线程完成
thread1.join()
(2)继承Thread
class MyThread(threading.Thread):# 可以给线程取名字def __init__(self, name):super().__init__(name=name)# 需要实现的核心代码def run(self):print("这里写核心功能")t = MyThread('线程1')
t.start()
t.join()
2.线程间通讯
同一个进程下的线程,使用的是同一块内存。因此天然可以进行通信。此外,还可以使用队列。参看第3部分。
# 定义任意类型的全局变量都可以实现线程间通信
lst = []def fun1():global lstlst.append(1)def fun2():global lstdt = lst.pop()print(dt)thread1 = threading.Thread(target=fun1)
thread2 = threading.Thread(target=fun2)
thread1.start()
thread2.start()
3.数据安全和锁的概念
先直接看一个例子
import threadingdef add():global totalfor i in range(1000000):total += 1def desc():global totalfor i in range(1000000):total -= 1if __name__ == '__main__':total = 0thread1 = threading.Thread(target=add)thread2 = threading.Thread(target=desc)thread1.start()thread2.start()thread1.join()thread2.join()print(total)
(1)数据安全
上面代码表示,有两个任务,分别由两个线程完成,定义了一个全局变量total。add函数可以理解为领工资,余额增加;desc函数可以理解为消费,余额减少。循环相同次数,每次变量值相同。可以想见,最终结果应该是0。可当你执行代码时,你会发现,结果不仅不为0,甚至每次结果都不相同。这样的结果,不仅不是我们想要的,而且是危险的,因为它变得不可控。而要解释这个现象,就需要了解程序是怎么执行的。
我们以为的是这样的:
(2)字节码
但实际却不是如此。因为代码在执行时,会先被编译为字节码,下面展示一个简单函数的字节码
# 定义一个简单函数
def add(a):a += 1return a# 查看编译后的字节码用dis
print(dis.dis(add))"""3 0 LOAD_FAST 0 (a)2 LOAD_CONST 1 (1)4 INPLACE_ADD6 STORE_FAST 0 (a)4 8 LOAD_FAST 0 (a)10 RETURN_VALUE
None
"""
可以看到,一个只包含变量自增功能的函数实际是分步骤完成的。加载变量a—>加载常量1—>执行加法—>保存a。程序在执行时是按照字节码行数和时间片在不同线程之间跳转的。不是我们看到的代码级更不是函数级来切换。因此,实际执行的可能过程示例如下:
关键点就在线程2修改变量这里,线程2在线程1完成修改前就已经加载了变量total,虽然total的值被线程1修改了,但线程2不会再次加载total。因此,最终执行的是0-1=-1。因此,循环次数越大,结果就越不具有确定性。
(3)锁
原理搞清楚了,但问题还没解决。而解决线程数据不安全的方法之一就是引入锁的机制。
从锁的字面含义就已经表明其解决的方式了-通过锁来保护变量。举个生活中的例子,一个变量相当于一个房间,房门有锁。A拿到钥匙,进去了,反锁了门。那么B就只有等着。等A办完事出来,交出钥匙,B拿到钥匙才能进入,B也会把门反锁。具体代码实现如下:
"""使用互斥锁解决数据安全问题"""
import threadingdef add():global totalfor i in range(1000000):# 本线程在访问total这个变量时,其他线程不能访问lock.acquire()total += 1# 释放锁lock.release()def desc():global totalfor i in range(1000000):lock.acquire()total -= 1lock.release()if __name__ == '__main__':total = 0lock = threading.Lock()thread1 = threading.Thread(target=add)thread2 = threading.Thread(target=desc)thread1.start()thread2.start()thread1.join()thread2.join()print(total)
锁不是万能的,使用锁会带来其他问题,比如死锁。简单讲就是A获得了锁,但A需要B的执行结果。可是B还没有获得锁,无法为A提供结果。最终出现相互等待。此外,同一个线程多次请求同一个资源,也会引起死锁。
(4)使用队列进行通信
上面的方式很基本,为了更方便实现安全的通信,可以使用queue.Queue(内部实现了锁),使用方法和多进程的队列一致。
q = queue.Queue(maxsize=10)
q.put(1)
dt = q.get()
print(dt)
4.多线程和多进程
(1)多进程是开启了多个独立的单元,相互之间不影响。是真正的并行,同时进行。但是多进程是有代价的。第一,独立就意味着通信更麻烦;第二,进程与进程的切换是很消耗计算机资源的。
(2)多线程因为是共享内存,因此通信可以很方便。但太方便就意味着可能失控。因此又引入了锁的机制。实际上python自带一个大锁——GIL——全局解释器锁。GIL的存在使得同一个时刻,只有一个线程在一个CPU上执行字节码,且无法将多个线程映射到多个CPU上。实际的执行示意图如下:
这样看来,多线程似乎是没有意义的。实际上,很多人都评论python的多线程是鸡肋。但,实际上。鸡肋吃起来还是有味道的。
线程1发送请求后,会进入漫长的等待。如果这个时候能切换到线程2,即等待的过程我可以做其他事情。那这样就会比单线程效率更高。
(3)总结
多IO操作的任务使用多线程,多CPU的任务使用多进程。线程的切换代价低于进程,但对于某些任务可能仍然是不可接收的,那是否能在一个线程内完成任务的切换呢?这就是协程的概念了。