博主前言:
通过第一篇文章的学习,读者已经认识了网络编程中的套接字编程,已经具备了实现基于TCP协议和基于UDP协议网络编程中客户端的实现。第二篇文章打算让读者感受一下多线程的魅力,通过仔细阅读本篇文章完全可达到一文入门多线程的目的。
1. 几个基本概念
1.1 单核CPU与多核CPU
CPU(central processing unit),即中央处理器,是作为计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元。
所谓单核CPU就是指同一时刻计算机只能执行一件事情。即电脑在同一时刻只运行一种程序。
那么多核CPU就是指同一时刻计算机可以执行多件事情。即电脑在同一时刻可执行多种程序。
1.2 并行与并发
我相信读到这儿读者肯定是有疑问的,为什么在配置单核CPU的计算机上既可以听音乐,同时又可以打开QQ聊天,同时还可以挂着游戏呢?
这一切的原因都是因为CPU运行的速度实在是太快了,一秒钟可以执行上百万次。在单核CPU的计算机上,我们先把音乐播放的软件拿来执行,让它执行0.0001s,然后再把QQ的程序拿来执行0.0001s,最后再把游戏的程序拿来执行0.0001s,循环往复。虽然我们执行的时间只有0.0001s,但是在CPU眼里,这个时间已经足够长了,所以达到了我们视觉上的效果,好像音乐、QQ、游戏同时在运行的样子。类似于现实生活中的翻书动画一般,如下图所示。
这是一种伪多任务的情况,对于CPU这种雨露均沾的操作,我们有一个名字叫做:时间片轮转。
同时,对于这种假的多任务,我们称之为并发。
那么是否存在真的多任务呢?
答案是肯定的,多核CPU就可以实现真的多任务。当三个程序在配置四核CPU的计算机上运行的时候,就可以称之为真的多任务。我们将这种真正的多任务称之为并行。
但是在实际的生活中,我们在电脑上运行的程序数量往往大于CPU的核数,也就是说,我们在现实生活中称的多任务,99%指的都是并发,而不是并行。
2. 多线程
线程是实现多任务的一种手段。通俗来讲,一个程序运行起来之后,一定有一个执行代码流程的次序,这个次序,就称之为线程。
2.1 单线程
单线程指的是在程序执行时,所走的程序路径按照连续顺序排下来,前面的必须处理好,后面的才会执行。
用一句话来形容单线程就是:单线程就是一心一意,用情专一的痴情少年。
我们通过一段代码来感受一下单线程。
# 引入time模块,定时使用
import time
# 定义唱跳rap篮球的函数
def sing():for i in range(3): # 用for循环来模拟实现print("我是蔡徐坤,我会唱。")time.sleep(1)def dance():for i in range(3):print("我是蔡徐坤,我会跳。")time.sleep(1)def rap():for i in range(3):print("我是蔡徐坤,我会rap。")time.sleep(1)def basketball():for i in range(3):print("我是蔡徐坤,我会篮球。")time.sleep(1)
# 定义主函数
def main(): sing()dance()rap()basketball()if __name__ == '__main__':main()
通过执行上述代码,运行结果如下图,我们可以看到一个会唱跳rap篮球的蔡徐坤,但是这个蔡徐坤同一时刻只能干一件事情,唱的时候不会跳,跳的时候不会rap,rap的时候不能篮球。
这就是一个单线程的程序代码,在执行dance函数之前,sing函数一定要执行完整,代码的执行按照规定的连续顺序依次执行,前面处理好,后面才能执行。那么,我们要怎样才能使多个函数“一起”执行呢?‘
2.2 多线程
要让多个函数“一起”执行,就要使用多线程了。
这里我们学习到一个新的模块:threading模块。
我们修改一下上述代码,将其多线程化。
import threading # 引入threading模块
import timedef sing():for i in range(3):print("我是蔡徐坤,我会唱。")time.sleep(1)def dance():for i in range(3):print("我是蔡徐坤,我会跳。")time.sleep(1)def rap():for i in range(3):print("我是蔡徐坤,我会rap。")time.sleep(1)def basketball():for i in range(3):print("我是蔡徐坤,我会篮球。")time.sleep(1)def main():test1 = threading.Thread(target=sing) #创建一个Tread对象test2 = threading.Thread(target=dance) #创建一个Tread对象test3 = threading.Thread(target=rap) #创建一个Tread对象test4 = threading.Thread(target=basketball) #创建一个Tread对象test1.start() #创建子线程test2.start() #创建子线程test3.start() #创建子线程test4.start() #创建子线程if __name__ == '__main__':main()
通过运行上述代码,我们可以清楚的认识到多线程和单线程的区别,不禁在心中大喊一声“多线程牛逼”。
实际上,多线程的使用更加广泛和深刻,结合我们上篇文章学习的套接字编程的内容,我们已经具备了编写实现一个基于UDP协议或TCP协议聊天室功能的小程序的能力,希望读者们可以实践实践。
2.3 主线程和子线程
在threading模块中,有一个enumerate的函数,此函数返回类型为列表类型,列表元素是线程的内容,元素的个数是线程的数量。我们可以通过enumerate函数证明,当调用Thread类的构造方法创建对象时,子线程还没有被创建,只有当该对象调用start方法时,子线程才被创建。
import threading
import timedef test():for i in range(3):print("This is a test")time.sleep(1)def main():print(threading.enumerate()) # 调用enumerate函数,查看在创建Thread对象之前的当前线程test1 = threading.Thread(target=test)print(threading.enumerate()) # 调用enumerate函数,查看在创建Thread对象之后的当前线程test1.start()print(threading.enumerate()) # 调用enumerate函数,查看Thread对象开始之后的当前线程if __name__ == '__main__':main()
我们在Thread对象创建的前、后,以及Thread对象调用开始函数之后,分别调用enumerate函数,查看当时线程情况,可得到以上图示情况。
此结果说明,我们在创建Thread对象的时候没有创建子线程,只有在Thread对象调用start函数时,子线程才被创建,并开始执行其相应代码。而且只有当子线程的代码运行结束之后,主线程才结束运行。
3. 共享全局变量
全局变量,即定义在函数外部的变量。每一个实例函数都可以对全局变量进行使用,我们想象一个情况,当子线程1和子线程2同时对一个全局变量进行使用修改的时候,其结果究竟会是怎样呢?
import threading
# 定义一个全局变量a,赋值为0
a = 0
def test1():# 修改全局变量a的值global afor i in range(100):a = a+1print("经过test1后a的值为:",a)def test2():# 修改全局变量a的值global afor i in range(100):a = a+1print("经过test2后a的值为:",a)def main():t1 = threading.Thread(target=test1)t2 = threading.Thread(target=test2)t1.start() # 创建子线程1t2.start() # 创建子线程2if __name__ == '__main__':main()
上段代码创建了一个子线程1和子线程2,两个线程同时对全局变量a进行加1操作,所示结果如上图。结果说明,当两个线程同时对全局变量进行使用的时候,可以共享使用。可,当我们将循环次数加到一百万时,运行结果又会如何呢?
当我们把循环次数加到足够大的时候,结果将会让我们大吃一惊!这是为什么呢?
这是因为我们在使用全局变量的时候,系统会解析成很多句话。
- 获取全局变量a的值
- 把获取的全局变量a值+1
- 把第2步所得的结果存储到全局变量a中
所以,当程序调用全局变量的时候,有可能子线程1执行到第二步的时候,子线程2就开始执行第一步,此时子线程2获取的全局变量a的值为子线程1此次执行调用的全局变量的值相同,因为子线程1此次执行没有调用第三步存储第二步的值。(读者请重点理解这段话)
3.1 互斥锁
为了防止当多个线程几乎同时修改某一个共享数据的时候,造成数据丢失,我们引入了互斥锁,进行同步控制。
互斥锁状态:锁定/非锁定
当某个线程更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程不能更改;直到该线程释放资源,将资源的状态变成非锁定,其他线程才能再次锁定该资源,互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。
我们把互斥锁加入上诉代码:
import threading
# 定义一个全局变量a,赋值为0
a = 0
# 创建互斥锁
mutex = threading.Lock()def test1():global a#锁定mutex.acquire()for i in range(1000000):a = a+1# 释放mutex.release()print("经过test1后a的值为:",a)def test2():global a#锁定mutex.acquire()for i in range(1000000):a = a+1# 释放mutex.release()print("经过test2后a的值为:",a)def main():t1 = threading.Thread(target=test1)t2 = threading.Thread(target=test2)t1.start()t2.start()if __name__ == '__main__':main()
我们调用threading模块的Lock函数创建互斥锁对象,同时将资源使用acquire函数上锁,当执行完毕后,调用release函数释放资源。
若上锁之前没有被上锁,那么此时上锁成功。如果上锁之前已经被上锁了,那么此时会堵塞在这里,直到该资源解锁。
3.2 死锁
在多线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源就会造成死锁。
如何有效避免死锁是开发者在实际开发过程中必须注意的事项。通常避免死锁的方法有
- 银行家算法
- 添加超时时间