python进阶系列 - 15讲 线程模块(threading)

53 阅读7分钟

在这篇文章中我们将详细讨论如何在Python中使用threading模块:

  • 如何去创建并启动多个线程
  • 如何等待多个线程完成
  • 如何在多个线程中使用共享数据
  • 如何使用 Lock 来防止竞争条件
  • 什么是守护线程
  • 如何使用 Queue 进行线程安全的数据/任务处理。

创建并启动线程

你可以使用threading.Thread()来创建一个线程。它需要两个重要的参数:

  • target: 一个调用对象(函数),这个线程将在线程启动时被调用
  • args: 函数的参数,必须是一个元组

看一个简单示例:通过thread.start()启动线程, 并使用thread.join()等待线程结束。

from threading import Thread


def square_numbers():
    for i in range(1000):
        result = i * i


if __name__ == "__main__":
    threads = []
    num_threads = 10
    # 创建线程并为每个线程分配一个函数
    for i in range(num_threads):
        thread = Thread(target=square_numbers)
        threads.append(thread)
    # 开始所有线程
    for thread in threads:
        thread.start()
    # 等待所有线程结束
    # 主线程阻塞,直到所有线程结束
    for thread in threads:
        thread.join()

共享数据

因为线程在同一个内存空间中,它们可以访问同一个(公共)数据。 因此,你可以简单地使用全局变量,该变量可以被所有线程读取和写入。

例子:创建两个线程,每个线程访问当前某个变量值, 并修改它(在这个例子中只是增加1)。 每个线程执行10次操作。

示例代码:

from threading import Thread
import time

# 所有线程都可以访问这个全局变量
database_value = 0


def increase():
    global database_value  # 全局变量
    local_copy = database_value  # 复制一份
    local_copy += 1
    time.sleep(0.1)
    database_value = local_copy  # 更新全局变量


if __name__ == "__main__":
    print('开始时:', database_value)
    t1 = Thread(target=increase)
    t2 = Thread(target=increase)
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print('结束时:', database_value)
    print('主函数结束')
开始时: 0
结束时: 1
主函数结束

注意,在上面的例子中,两个线程应该递增值1。 所以,2个递增操作被执行。 但是为什么结束值是1而不是2?

竞争条件

这里发生了一种竞争情况。 当两个或多个线程可以访问共享数据并且它们尝试同时更改它时,就会出现竞争条件。 因为线程调度算法可以随时在线程之间进行交换, 但你不知道线程尝试访问共享数据的顺序。

在我们的例子中,第一个线程访问 database_value (0) 并将其存储在本地副本中。 然后增加1(local_copy 现在是 1)。 使用的time.sleep() 函数,它只是模拟一些耗时的操作,程序将同时切换到第2个线程。

切换到第2个线程时,检索当前的 database_value(仍为 0)并将 local_copy 增加到 1。

现在两个线程都有一个值为 1 的本地副本,因此两个线程都会将 1 写入全局 database_value

这就是为什么最终值为 1 而不是 2。

使用锁避免竞争条件

锁(也称为互斥锁)是一种同步机制,用于在有许多执行线程的环境中强制限制对资源的访问。

锁有两种状态:lockedunlocked

如果状态被锁定,则不允许其他并发线程进入此代码段,直到状态再次解锁。

两个功能很重要:

  • lock.acquire() : 这将锁定状态并阻塞
  • lock.release() : 这将再次解锁状态。

重要提示:应该始终在获得锁后再次释放它!

在我们的示例中,获取和修改数据值的关键代码部分现在被锁定。 这可以防止第二个线程同时修改全局数据。

代码没有太大变化,只是对关键资源增加了获取锁及释放锁的代码:

from threading import Thread, Lock
import time

# 所有线程都可以访问这个全局变量
database_value = 0


def increase(lock):
    global database_value  # 全局变量
    lock.acquire()  # 上锁
    local_copy = database_value  # 复制一份
    local_copy += 1
    time.sleep(0.1)
    database_value = local_copy  # 更新全局变量
    lock.release()  # 解锁


if __name__ == "__main__":
    lock = Lock()
    print('开始时:', database_value)
    t1 = Thread(target=increase, args=(lock,))
    t2 = Thread(target=increase, args=(lock,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print('结束时:', database_value)
    print('主函数结束')
开始时: 0
结束时: 2
主函数结束

使用锁作为上下文管理器

lock.acquire() 之后,你永远不要忘记调用 lock.release() 来解锁代码。

推荐使用使用锁作为上下文管理器,它将安全地获取锁和释放锁,如下代码:

import time

def increase(lock):
    global database_value
    with lock:
        local_copy = database_value
        local_copy += 1
        time.sleep(0.1)
        database_value = local_copy

在 Python 中使用队列

队列可用于多线程和多进程处理环境中的安全地进行数据交换和数据处理。

队列是遵循先进先出 (FIFO) 原则的线性数据结构。 一个很好的例子是排队等候的客户队列,首先为先到的客户提供服务。

代码:

from queue import Queue

q = Queue()  # 创建一个队列
# 增加一个元素
q.put(1)  # 1
q.put(2)  # 2 1
q.put(3)  # 3 2 1 
# 输出存储形式:
# 后 --> 3 2 1 --> 前
# 取出元素会是最前面的
first = q.get()  # --> 1
print(first)
# 取出后,队列中只剩下 2 1

结果:

1

在多线程中使用队列

操作队列是多线程安全的。主要方法:

  • q.get() : 删除并返回第一项。默认情况下,它会阻塞,直到该项可用。
  • q.put(item) : 将元素放在队列的末尾。默认情况下,它会阻塞,直到有空闲插槽可用。
  • q.task_done() : 表示之前入队的任务已完成。对于每个 get(),应该在完成任务后调用它。
  • q.join() : 阻塞直到队列中的所有任务都被获取和处理(每个任务都调用了task_done())。
  • q.empty() : 如果队列为空,则返回 True。

以下示例使用队列来交换 0...19 中的数字。 每个线程调用worker方法。在无限循环中,由于阻塞q.get()调用,线程正在等待直到队列有元素可用。 当可用时,它们会被处理(此处仅打印),然后 q.task_done() 告诉队列处理完成。

在主线程中,创建了10个daemon线程(守护线程)。 这意味着当主线程结束时它们会自动结束,因此不再执行worker方法和无限循环。

然后队列被项目填满,worker 方法可以继续处理可用的项目。 最后 q.join() 需要阻塞主线程,直到所有项目都被获取和处理。

代码:

from threading import Thread, Lock, current_thread
from queue import Queue


def worker(q, lock):
    while True:
        value = q.get()  # 阻塞直到元素可用
        with lock:
            # 保护打印的时候不会被其他线程打印
            ctid = int(current_thread().name)
            print(f"在线程{ctid}中得到了:{value}")
        q.task_done()


if __name__ == '__main__':
    q = Queue()
    num_threads = 10
    lock = Lock()
    for i in range(num_threads):
        t = Thread(name=f"{i + 1}", target=worker, args=(q, lock))
        t.daemon = True  # 当主线程结束时,子线程也会结束
        t.start()
    # 填充队列
    for x in range(20):
        q.put(x)
    q.join()  # 直到队列中的所有任务都被获取并处理。
    print('主程序完成。')

结果:

在线程1中得到了:0
在线程2中得到了:1
在线程2中得到了:11
在线程3中得到了:3
在线程3中得到了:13
在线程3中得到了:14
在线程3中得到了:15
在线程3中得到了:16
在线程3中得到了:17
在线程3中得到了:18
在线程3中得到了:19
在线程8中得到了:5
在线程4中得到了:2
在线程10中得到了:7
在线程6中得到了:9
在线程1中得到了:8
在线程7中得到了:4
在线程9中得到了:6
在线程2中得到了:12
在线程5中得到了:10
主程序完成。

守护线程

在上面的示例中,使用了守护线程。 守护线程是在主程序结束时自动终止的后台线程。

如果没有守护进程,我们将用诸如threading.Event之类的信号机制来停止工作进程。 但要小心守护进程:它们突然停止,它们的资源(例如打开的文件或数据库事务)可能无法正确释放/完成。

小节

本文深入介绍了Python线程threading用法,多个线程需要小心共享变量,并正确使用锁机制保证数据正确。

感谢你的阅读。欢迎大家点赞、收藏、支持!

pythontip 出品,Happy Coding!

公众号: 夸克编程

我们的小目标: 让天下木有难学的Python!