Python 多线程

Python 进程和线程

在学习 Python 多线程编程之前,小伙伴们必须要把 “进程” 和 “线程” 这 2 个概念理解透彻了。

1. 进程是什么?

在介绍进程之前,我们需要知道什么是多任务。所谓的多任务,简单点来说,就是操作系统能够执行多个任务。比如在咱们的 Windows 系统中,能够同时看电影、聊天、浏览网页等。其中,每一个任务就是一个进程。

实际上,我们可以使用 “Ctrl + Shift + Esc” 组合键打开任务管理器,就可以查看系统正在执行的进程。在下图中,此时系统的进程有 8 个。

Python 多线程

2. 线程是什么?

进程,又被称之为 “重量级进程”。线程,又被称之为 “轻量级进程”。一个应用程序至少有一个进程,一个进程至少有一个线程。在一个进程中,至少要有一条线程作为这个程序运行的入口点。其中,这条线程又被称之为 “主线程”。

我们一定要清楚,进程与进程之间是相互独立。在多进程中,即使是同一个变量,都会各自有一个备份在每个进程中,互不影响。但是在同一个进程中,线程与线程之间是内存共享的,所有线程共享所有的变量。

从上面也可以知道,由于进程间是独立的,因此一个进程的崩溃不会影响其他进程。而线程是包含在进程之内的,线程的崩溃就会引发进程的崩溃,继而导致同一个进程内其他线程也崩溃。

线程和进程最主要的区别,是在于它们管理操作系统资源的不同。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其他进程产生影响,而线程只是一个进程中的不同执行路径。线程有自己的栈和局部变量,但线程之间没有单独的地址空间,一个线程终止就等于整个进程终止,所以多进程的程序要比多线程的程序健壮。

一个程序采用多线程处理,可以使得并发性更高,效率更快。在多线程中,多个线程共享内存,从而极大地提高了程序的运行效率。对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。

多线程编程,对于具有以下特点的任务而言是非常理想的:

  • 本质上是异步的。
  • 需要多个并发活动。
  • 每个活动的处理顺序可能是不确定的,或者说是随机的、不可预测的。

对于初学者来说,进程和线程的概念比较抽象。建议小伙伴们把整一章学完了,再回到这里看一下就非常清楚了。

Python 多线程编程

在 Python3 中,我们有 2 种方式来实现线程,一种是 “_thread 模块”,另外一种是 “threading 模块”。其中,_thread 是低级模块,threading 模块是高级模块。由于 threading 模块中包含 _thread 模块所有的方法,因此我们只需要掌握 threading 模块就可以了。事实上,在实际开发中,对于多线程编程,我们也是不推荐使用 _thread 模块的,而是推荐使用 threading 模块。

1. Python 多线程处理

在 Python 中,threading 模块为我们提供了一个 Thread 类,我们可以通过这个来创建一个线程对象。

语法:

import threading
t = threading.Thread(target=函数名 , args=参数元组)

说明:

Thread 类常用参数有 2 个:target 和 args。参数 target 取值是一个函数名,参数 args 取值是一个参数元组。

示例 1:Python 使用单线程

import time

def loop1():
    print('开始第1个循环:', time.ctime())
    time.sleep(4)
    print('结束第1个循环:', time.ctime())
def loop2():
    print('开始第2个循环:', time.ctime())
    time.sleep(2)
    print('结束第2个循环:', time.ctime())

if __name__ == '__main__':
    loop1()
    loop2()

运行结果如下。

开始第 1 个循环:Mon Dec  3 19:05:34 2018
结束第 1 个循环:Mon Dec  3 19:05:38 2018
开始第 2 个循环:Mon Dec  3 19:05:38 2018
结束第 2 个循环:Mon Dec  3 19:05:40 2018

分析:

这里我们在一个单线程程序中连续执行 2 个循环,只有当第 1 个循环结束后,才能开始第 2 个循环,总共消耗的时间是每个循环所有时间之和,也就是 6 秒钟。

可能这里很多小伙伴对使用 time.sleep(4)、time.sleep(2) 这两句代码不理解,不知道它们放到这里究竟有什么意义。其实你可以这样去理解:一个循环相当于 “应用程序的一部分”,time.sleep(4) 相当于 “这部分执行的时间”。只是我们为了方便讲解,使用了 time.sleep(4) 简化了这个应用程序的内部逻辑而已。

示例 2:Python 使用多线程

import time,threading

def loop1():
    print('开始第1个循环:', time.ctime())
    time.sleep(4)
    print('结束第1个循环:', time.ctime())
def loop2():
    print('开始第2个循环:', time.ctime())
    time.sleep(2)
    print('结束第2个循环:', time.ctime())

if __name__ == '__main__':
    t1 = threading.Thread(target=loop1, args=())
    t1.start()
    t2 = threading.Thread(target=loop2, args=())
    t2.start()

运行结果如下。

开始第 1 个循环:Mon Dec  3 19:08:00 2018
开始第 2 个循环:Mon Dec  3 19:08:00 2018
结束第 2 个循环:Mon Dec  3 19:08:02 2018
结束第 1 个循环:Mon Dec  3 19:08:04 2018

分析:

在这个例子中,我们使用了多线程来处理这两个循环。从输出的结果可以看出,执行完两个循环我们只用了 4 秒,也就是最长循环的执行时间加上其他所有开销的时间之和。相对于单线程的 6 秒,我们整整节省了 2 秒。其中,睡眠 4 秒和睡眠 2 秒这两个代码片段是并发执行的,这样有助于减少整体的运行时间。

此外,如果 args 参数的值是一个空元组,此时这个参数是可以省略的。也就是说,下面两句代码是等价的。

t1 = threading.Thread(target=loop1, args=())
t1 = threading.Thread(target=loop1)

示例 3:Python 多线程的特点

import threading

def test(x, y):
    for i in range(x, y):
        print(str(i * i) + ';')
# 第 1 个线程
t1 = threading.Thread(target=test, args=(1, 6))
t1.start()

# 第 2 个线程
t2 = threading.Thread(target=test, args=(6, 11))
t2.start()

运行结果如下。

1;36;
4;49;
9;64;
16;81;
25;100;

分析:

在这个例子中,我们首先定义了一个函数 test()。在函数的内部,我们使用了一个 for 循环来输出指定范围内某个数的平方。从输出结果可以很直观地看出来,多线程处理的特点是并发执行。

了解了多线程的特点之后,接下来我们就不再对比单线程和多线程了,而是介绍怎么来使用多线程。

2. 线程等待

在 Python 中,我们可能会碰到这样的情况:某个线程执行了一部分,然后它得等待另外一个线程执行完成后,才能执行接下来部分。想要实现这样的效果,此时我们需要借助 Thread 类的 join() 方法。

语法:

t.join()

说明:

对于 join() 方法的使用,具体是这样的:首先我们有两个线程,即 A 和 B。如果在线程 A 中调用 B.join(),那么线程 A 执行到 B.join() 这一句代码时,就会开始等待。然后直到线程 B 执行完了,线程 A 才会执行接下来的部分。

这里我们一定要记住,join() 方法是在某一个线程的内部来调用的。

示例 4:线程等待

import time,threading

def loop1():
    print('开始第1个循环')
    time.sleep(4)
    print('结束第1个循环')
def loop2(t1):
    print('开始第2个循环')
    t1.join()      # 线程等待
    time.sleep(2)
    print('结束第2个循环')

if __name__ == '__main__':
    t1 = threading.Thread(target=loop1, args=() )
    t1.start()
    t2 = threading.Thread(target=loop2, args=(t1,))
    t2.start()

运行结果如下。

开始第 1 个循环
开始第 2 个循环
结束第 1 个循环
结束第 2 个循环

分析:

在这个例子中,我们使用了多线程使得 t1 和 t2 并行执行,不过由于在 t2 中调用了 t1.join(),因此 t2 需要等待 t1 执行完了才能执行后面部分。也就是先结束第 1 个循环,再结束第 2 个循环。因此输出的顺序为:

开始第 1 个循环
开始第 2 个循环
结束第 1 个循环
结束第 2 个循环

如果删除 t1.join() 这一句代码,也就是不采用线程等待。由于 t1 和 t2 是并行执行的,t1 执行时间为 4 秒,t2 执行时间为 2 秒,那么 t2 会比 t1 先执行完。也就是先结束第 2 个循环,再结束第 1 个循环。此时输出顺序就会变成:

开始第 1 个循环
开始第 2 个循环
结束第 2 个循环
结束第 1 个循环

从上面这个例子,小伙伴们也可以很直观看出来线程等待具体是怎么一回事了。

此外我们还要注意一下元组的写法。在下面 2 种写法中,第 1 种写法是正确的,第 2 种写法是错误的。这是因为 args 参数的值是一个元组,只有一个元素的元组应该写成 (t1,) 而不是 (t1)。很多小伙伴都会忘记这一点,这里顺便提醒一下大家。

# 正确的写法
t2 = threading.Thread(target=loop2, args=(t1,))

# 错误的写法
t2 = threading.Thread(target=loop2, args=(t1))

3. 锁

我们都知道,在同一个进程中,多个线程是共享内存的,因此假如某一个变量被某一个线程改变,那么对于所有的线程来说,该变量的值是已经改变的了。

既然是共享内存,那么我们可能就会碰到这样的情况:多个线程 “同时” 对某个变量进行修改。这个时候如果不采用一定的手段来处理的话,那么就会造成这样的结果:线程 A 将变量改为 v1,线程 B 将变量改为 v2。这样问题就来了,变量最终的值是 v1 呢,还是 v2 呢?

我们举一个简单的例子:比如一个列表中所有元素都是 0,线程 A 从后往前把所有元素修改为 1,而线程 B 从前往后输出列表每一个元素。那么,可能当线程 A 开始修改的时候,线程 B 就开始输出列表了,输出就变成了一半 0 一半 1,这就造成了数据的不同步。

为了避免这种情况,我们就引入了 “锁” 的概念。“锁” 的出现,就是用于解决 “线程不同步” 的问题。其中,锁有 2 种状态,分布是 “锁定” 和 “未锁定”。每当一个线程(比如上面的线程 A)要访问共享数据时,必须先获得 “锁定” 这种状态。如果已经有别的线程(比如上面的线程 B)获得 “锁定” 状态了,那么此时就让线程 A 暂停(也就是同步阻塞),等到线程 B 访问完成并释放锁以后,再让线程 A 去访问。经过这样的处理,在输出列表时要么全部输出 0,要么全部输出 1,不会再出现一半 0 一半 1 的情况。

在 Python 中,我们可以使用 threading 模块中的 Lock 类来实现锁的功能。

语法:

l = threading.Lock()      # 创建锁
l.acquire()               # 设置 “锁定” 状态
l.release()               # 释放 “锁定” 状态

说明:

acquire() 方法用于设置 “锁定” 状态,也就是上锁。release() 方法用于释放 “锁定” 状态,也就是开锁。

示例 5:没有使用 “锁”

import time, threading

def setList():
    for i in range(0, 4):
        items[3-i] = 1
        time.sleep(0.5)
def getList():
    for i in range(0, 4):
        print(items[i])
        time.sleep(0.5)

if __name__ == '__main__':
    items = [0, 0, 0, 0]
    t1 = threading.Thread(target=setList, args=())
    t1.start()
    t2 = threading.Thread(target=getList, args=())
    t2.start()

运行结果如下。

0
0
1
1

分析:

在上面这个例子中,我们定义了两个函数:setList() 和 getList()。setList() 用于设置列表所有元素为 1,getList() 用于输出列表每一个元素。由于这里没有采用锁来处理,因此最终输出结果是一半 0 一半 1。接下来,我们再看一下怎么使用 “锁” 来解除冲突情况的。

示例 6:使用 “锁”

import time, threading

def setList():
    # 使用 with 语句自动管理锁定与释放
    with l:
        for i in range(0, 4):
            items[3-i] = 1
            time.sleep(0.5)

def getList():
    # 使用 with 语句自动管理锁定与释放
    with l:
        for i in range(0, 4):
            print(items[i])
            time.sleep(0.5)

if __name__ == '__main__':
    items = [0, 0, 0, 0]
    l = threading.Lock()     # 实例化 Lock 类
    t1 = threading.Thread(target=setList, args=())
    t1.start()
    t2 = threading.Thread(target=getList, args=())
    t2.start()

运行结果如下。

1
1
1
1

分析:

锁的实现很简单,只需要在可能会造成冲突的所有线程的 “开始处” 使用 acquire() 方法,并且 “结尾处” 使用 release() 方法就可以了。

在这个例子中,time.sleep(0.5) 这一句代码是用于增加执行时间。如果没有这一句代码,执行的时间太短,会导致结果并不直观。

接下来我们再来看一个有实际用途的例子,这个例子将要实现的功能是:假设电影院某个场次只有 100 张电影票,10 个用户同时抢购该电影票(注意这里是 “同时”),每售出一张,就显示一次剩余的电影票张数。

示例 7:没有使用 “锁”

import time, threading

def task():
    global n
    temp = n
    time.sleep(0.1)
    n = temp - 1
    print('购买成功,剩余' + str(n) + '张')

if __name__ == '__main__':
    n = 100       # 共 100 张票
    ts = []       # 初始化一个列表
    for i in range(10):
        t = threading.Thread(target=task, args=())
        ts.append(t)
    for t in ts:
        t.start()

运行结果如下。

购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99 张
购买成功,剩余 99

分析:

这里我们使用了多线程处理,由于 10 个用户是买票的时候是同时进行的,因此剩下的票数都是 99,这显然不是我们想要的结果。

示例 8:使用 “锁”

import time, threading

def task():
    global n
    # 使用 with 语句自动管理锁定与释放
    with l:
        temp = n
        time.sleep(0.1)
        n = temp - 1
        print('购买成功,剩余' + str(n) + '张')

if __name__ == '__main__':
    n = 100
    # 共 100 张票
    ts = []
    # 初始化一个列表
    l = threading.Lock()      # 实例化 Lock 类

    for i in range(10):
        t = threading.Thread(target=task, args=())
        ts.append(t)
    for t in ts:
        t.start()

运行结果如下。

购买成功,剩余 99 张
购买成功,剩余 98 张
购买成功,剩余 97 张
购买成功,剩余 96 张
购买成功,剩余 95 张
购买成功,剩余 94 张
购买成功,剩余 93 张
购买成功,剩余 92 张
购买成功,剩余 91 张
购买成功,剩余 90

分析:

使用了 “锁” 之后,输出的结果就是我们预期的结果了。

常见问题

1. Python 的多线程真的比单线程快吗?

在很多其他编程语言(比如 C++、Java、Go 等)中,多线程确实能极大提升所有计算的效率。但在 Python 中,有一个非常特殊的底层机制叫做 “GIL(全局解释器锁)”。

GIL 就像是一个唯一的通行证,Python 规定:同一时刻,无论你有多少个线程,真正能在 CPU 上执行的只有拿到通行证的那 1 个线程。

  • 什么时候用多线程?当你的任务是 “网络请求、文件读写、下载东西” 时(即 I/O 密集型),多线程非常牛!因为线程在等待网络响应时会放下通行证,让其他线程去干活。
  • 什么时候千万别用多线程?当你的任务是 “极其复杂的数学运算、图像处理” 时(即 CPU 密集型),多线程反而会因为大家互相抢通行证而变得比单线程还慢!此时,我们应该使用 “多进程(multiprocessing)” 来解决。

上一篇: Python 枚举类型

下一篇: Python 正则表达式

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号