跳转至主要内容

Python编程

Python多线程编程:如何确保线程安全?

Sprite
发表于 2024年10月19日

什么是多线程?

多线程是一种让程序可以同时做几件事情的技术。想象一下,你在厨房里做饭:你可以一边煮汤,一边切菜,甚至一边烤面包。这就是多线程的工作原理:让程序在同一时间执行多个任务。

为什么要用多线程?

  • 提高效率:特别是在需要等待某些事情发生的情况下,比如下载文件或读取数据库。通过多线程,程序可以在等待的同时去做其他事情,而不是一直闲着。
  • 响应性:如果你在做一些长时间的操作,比如请求网络数据,使用多线程可以让程序在等待的同时仍然能够响应用户的操作,比如点击按钮。

Python多线程的使用场景

  1. I/O 密集型任务:比如文件读写、网络请求、数据库操作等。这类任务常常需要等待外部资源,而在等待的时间里,其他线程可以继续执行,避免时间浪费。
  2. Web 服务器:多线程可以让服务器同时处理多个客户端请求,避免因为某个请求阻塞整个服务器。

不使用多线程的情况

我们用一个网络请求的例子。假设我们需要请求多个网站的内容

import requests
import time

# 待请求的 URL 列表
urls = [
    "https://www.example.com",
    "https://www.bing.com",
    "https://www.yahoo.com",
]

# 定义一个函数来请求 URL 并打印响应状态码
def fetch_url(url):
    print(f"开始请求 {url}")
    response = requests.get(url)
    print(f"完成请求 {url}, 状态码: {response.status_code}")

# 记录开始时间
start_time = time.time()

# 按顺序逐一请求每个 URL
for url in urls:
    fetch_url(url)

# 记录结束时间
end_time = time.time()

# 打印所有请求的总耗时
print(f"所有请求完成,总耗时: {end_time - start_time:.2f} 秒")

运行上面代码

开始请求 https://www.example.com
完成请求 https://www.example.com, 状态码: 200
开始请求 https://www.bing.com
完成请求 https://www.bing.com, 状态码: 200
开始请求 https://www.yahoo.com
完成请求 https://www.yahoo.com, 状态码: 200
所有请求完成,总耗时: 4.96 秒

我们可以看到,程序会依次请求列表中的每个 URL,并打印每个请求的状态码和总的执行时间。由于没有使用多线程,请求将是串行的,所有请求完成后再输出总耗时4.96秒。在下面,我们使用多线程处理网络请求

使用多线程的情况:threading 模块

threading 模块实现的是多线程,也就是在同一个进程里同时运行多个任务。线程是轻量级的,因为它们共享同一个内存空间。不过,Python 有个叫 GIL(全局解释器锁)的东西,限制了在同一时间只能有一个线程在执行 Python 代码,这就意味着多线程在某些情况下不能发挥出真正的并行效果,比如那种计算密集型的任务。但是对于IO密集型的任务,多线程可以显著提升效率。

下面是一个使用 threading 模块执行上面网络请求的例子。假设在请求多个网站的内容时使用多线程。

import threading
import requests
import time

# 待请求的 URL 列表
urls = [
    "https://www.example.com",
    "https://www.bing.com",
    "https://www.yahoo.com",
]

# 定义一个函数来请求 URL 并打印响应状态码
def fetch_url(url):
    print(f"开始请求 {url}")
    response = requests.get(url)
    print(f"完成请求 {url}, 状态码: {response.status_code}")

# 用于跟踪线程的列表
threads = []

# 记录开始时间
start_time = time.time()

# 为每个 URL 创建一个线程
for url in urls:
    # 创建一个线程,每个线程执行 fetch_url 函数
    thread = threading.Thread(target=fetch_url, args=(url,))
    threads.append(thread)  # 将线程添加到线程列表
    thread.start()  # 启动线程

# 等待所有线程完成
for thread in threads:
    thread.join()  # 等待每个线程完成

# 记录结束时间
end_time = time.time()

# 打印所有请求的总耗时
print(f"所有请求完成,总耗时: {end_time - start_time:.2f} 秒")

运行上面代码

开始请求 https://www.example.com
开始请求 https://www.bing.com
开始请求 https://www.yahoo.com
完成请求 https://www.bing.com, 状态码: 200
完成请求 https://www.example.com, 状态码: 200
完成请求 https://www.yahoo.com, 状态码: 200
所有请求完成,总耗时: 2.60 秒

我们可以看到使用多线程可以让所有请求几乎同时进行,节省了总的运行时间,共2.60秒。如果不使用多线程,而是按顺序请求,每个请求都需要等待完成,整个过程会耗费更多时间。

但是现在有一种情况,如果在多个线程需要共享某些资源(比如修改一个全局变量或写入一个文件),就可能出现线程安全问题。

举个例子,假设在我们的网络请求例子中,我们想要统计成功请求的数量。如果我们不使用同步机制来保证线程的安全访问,多个线程可能会同时修改这个计数器变量,导致统计结果不准确。例如,两个线程可能都读取到相同的初始值,然后各自递增,导致最终的计数结果比预期的少。

那么要如何解决这个问题?我们看下面的线程同步机制

线程同步机制

Python 提供了几种同步机制,最常用的是Lock)和条件变量Condition)。它们可以确保多个线程在同一时刻只有一个线程访问共享资源,从而避免数据竞争。

锁(Lock)

锁是一种简单的线程同步工具。你可以把它想象成一个”开关”,只有获得锁的线程可以执行访问共享资源的操作,其他线程必须等待锁被释放。

import threading
import requests
import time

# 待请求的 URL 列表
urls = [
    "https://www.example.com",
    "https://www.bing.com",
    "https://www.yahoo.com",
]

# 定义一个全局计数器来统计成功请求的次数
successful_requests = 0

# 创建一个锁对象
lock = threading.Lock()

# 定义一个函数来请求 URL,并更新计数器
def fetch_url(url):
    global successful_requests
    print(f"开始请求 {url}")
    response = requests.get(url)

    # 使用锁来同步对计数器的访问
    with lock:
        if response.status_code == 200:
            successful_requests += 1
            print(f"{url} 请求成功")

# 用于跟踪线程的列表
threads = []

# 记录开始时间
start_time = time.time()

# 为每个 URL 创建一个线程
for url in urls:
    thread = threading.Thread(target=fetch_url, args=(url,))
    threads.append(thread)
    thread.start()

# 等待所有线程完成
for thread in threads:
    thread.join()

# 记录结束时间
end_time = time.time()

# 打印所有请求的总耗时
print(f"所有请求完成,总耗时: {end_time - start_time:.2f} 秒")
print(f"成功请求的数量: {successful_requests}")

在上面代码中,我们创建了一个锁对象,lock = threading.Lock(),用于控制对共享变量 successful_requests 的访问。在更新全局计数器 successful_requests 的地方,我们使用了 with lock: 语句。这个语句会自动调用 lock.acquire() 来获取锁,并在执行完后自动调用 lock.release() 释放锁。这样可以确保在一个线程执行更新操作时,其他线程不能同时修改计数器,避免数据竞争。

如果我们不使用锁,多个线程可能会在同一时间读取和更新 successful_requests,导致两个线程同时读取到相同的计数值,比如 1,然后各自递增并保存,结果 successful_requests 只增加到 2,而不是 3。

锁的开销

虽然锁能够确保线程安全,但它也会带来一些性能上的开销。当一个线程获取了锁,其他线程必须等待锁被释放才能继续执行,这种等待会造成一定的性能损失,特别是当多个线程频繁地争夺同一个锁时。过多的锁定操作还可能导致死锁问题,这在更复杂的多线程程序中需要小心处理。

其他同步机制

除了锁之外,Python 还提供了其他同步工具:

  • RLock(可重入锁):允许同一个线程多次获取同一把锁。适合递归调用的场景。
  • Condition(条件变量):适用于线程之间的更复杂的同步需求,例如一个线程需要等待另一个线程完成某个条件后才能继续执行。
  • Semaphore(信号量):控制同时允许访问共享资源的线程数量。

这些工具可以根据不同的场景灵活选择使用。

总结

Python 的多线程技术可以让我们在程序中同时处理多个任务,尤其是在处理 I/O 密集型任务时,比如网络请求、文件读写等,可以显著提高程序的效率和响应能力。然而,在某些情况下,多线程的效果并不理想。这些情况包括:CPU 密集型任务、大规模数据处理等,这种情况就需要使用多进程来解决。

评论已关闭。