Python 信号量详解:并发控制实战

Python 信号量详解:并发控制实战

  • [一、Python 信号量详解](#一、Python 信号量详解)
    • 1、引言
    • [2、 什么是信号量](#2、 什么是信号量)
      • [2.1 、信号量的核心概念](#2.1 、信号量的核心概念)
    • 3、信号量的基本用法
      • [3.1、 创建信号量](#3.1、 创建信号量)
      • [3.2、 获取与释放](#3.2、 获取与释放)
      • [3.3、 完整示例:限制并发线程数](#3.3、 完整示例:限制并发线程数)
    • 4、信号量的高级用法
      • [4.1、 非阻塞获取](#4.1、 非阻塞获取)
      • [4.2 、带超时的获取](#4.2 、带超时的获取)
      • [4.3 、有界信号量](#4.3 、有界信号量)
      • [4.4、 信号量与锁的区别](#4.4、 信号量与锁的区别)
    • [5、 信号量的实际应用场景](#5、 信号量的实际应用场景)
      • [5.1 、连接池管理](#5.1 、连接池管理)
      • [5.2、 限流控制](#5.2、 限流控制)
      • [5.3 、生产者-消费者模式](#5.3 、生产者-消费者模式)
    • 6、多进程中的信号量
    • [7、 注意事项与常见陷阱](#7、 注意事项与常见陷阱)
      • [7.1 、死锁风险](#7.1 、死锁风险)
      • [7.2 、忘记释放](#7.2 、忘记释放)
      • [7.3 、信号量与 GIL](#7.3 、信号量与 GIL)
      • [7.4、 性能开销](#7.4、 性能开销)
    • [8、 总结](#8、 总结)
  • 二、代码示例

一、Python 信号量详解

1、引言

在多线程与多进程编程中,控制并发访问共享资源是一项核心挑战。Python 提供了多种同步原语,其中信号量(Semaphore)是一种灵活且强大的工具,用于限制同时访问某个资源的线程或进程数量。本文将基于 Python 最新版本,深入讲解信号量的原理、用法、常见应用场景以及注意事项,帮助你写出更健壮的并发程序。

2、 什么是信号量

信号量是一种计数器,用于控制对共享资源的访问权限。它维护一个内部计数器,当线程或进程需要访问资源时,必须先获取信号量(计数器减一);当访问结束时,释放信号量(计数器加一)。当计数器为零时,后续的获取操作将被阻塞,直到有其他线程释放信号量。

信号量由计算机科学家 Edsger Dijkstra 于 1965 年提出,是操作系统中经典的同步机制。在 Python 中,信号量被封装在 threadingmultiprocessing 两个标准库中,接口保持一致。

2.1 、信号量的核心概念

  • 计数器(Counter):表示当前可用的资源数量。
  • 获取(Acquire):计数器减一,若计数器为零则阻塞等待。
  • 释放(Release):计数器加一,并唤醒一个等待的线程或进程。

3、信号量的基本用法

Python 中信号量的使用非常简单,核心 API 包括构造、获取和释放三个操作。

3.1、 创建信号量

python 复制代码
import threading

# 创建一个初始值为 3 的信号量
semaphore = threading.Semaphore(3)

multiprocessing 模块中,用法完全一致:

python 复制代码
from multiprocessing import Semaphore

semaphore = Semaphore(3)

3.2、 获取与释放

python 复制代码
# 获取信号量(计数器减一)
semaphore.acquire()

# 释放信号量(计数器加一)
semaphore.release()

推荐使用上下文管理器(with 语句),它可以确保即使发生异常也能正确释放信号量:

python 复制代码
with semaphore:
    # 访问共享资源
    pass

3.3、 完整示例:限制并发线程数

下面是一个完整的示例,演示如何使用信号量限制同时运行的线程数量:

python 复制代码
import threading
import time

# 创建一个最多允许 2 个线程同时访问的信号量
semaphore = threading.Semaphore(2)

def worker(worker_id):
    with semaphore:
        print(f"Worker {worker_id} 开始工作")
        time.sleep(2)
        print(f"Worker {worker_id} 结束工作")

threads = []
for i in range(5):
    t = threading.Thread(target=worker, args=(i,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

运行结果:

复制代码
Worker 0 开始工作
Worker 1 开始工作
Worker 0 结束工作
Worker 2 开始工作
Worker 1 结束工作
Worker 3 开始工作
Worker 2 结束工作
Worker 4 开始工作
Worker 3 结束工作
Worker 4 结束工作

可以看到,任意时刻最多只有 2 个线程在同时执行。

4、信号量的高级用法

4.1、 非阻塞获取

acquire() 方法支持 blocking 参数,可以实现非阻塞获取:

python 复制代码
# 非阻塞获取,若信号量不可用则立即返回 False
if semaphore.acquire(blocking=False):
    try:
        # 访问共享资源
        pass
    finally:
        semaphore.release()
else:
    print("信号量不可用,稍后再试")

4.2 、带超时的获取

从 Python 3.x 开始,acquire() 支持 timeout 参数,可以在指定时间内等待信号量:

python 复制代码
# 最多等待 3 秒
if semaphore.acquire(timeout=3):
    try:
        # 访问共享资源
        pass
    finally:
        semaphore.release()
else:
    print("等待超时,未能获取信号量")

4.3 、有界信号量

threading.BoundedSemaphore 是信号量的一个变体,它确保释放次数不会超过获取次数,从而避免因编程错误导致计数器无限增长:

python 复制代码
import threading

# 有界信号量
bounded_semaphore = threading.BoundedSemaphore(2)

bounded_semaphore.acquire()
bounded_semaphore.release()
bounded_semaphore.release()  # 这里会抛出 ValueError

运行上述代码会抛出 ValueError: Semaphore released too many times,帮助开发者及时发现错误。

4.4、 信号量与锁的区别

信号量和锁(Lock)都是同步原语,但它们的语义不同:

特性 锁(Lock) 信号量(Semaphore)
计数器 只有 0 和 1 两种状态 可以是任意非负整数
用途 互斥访问 控制并发数量
可重入 否(RLock 可重入)
典型场景 保护临界区 限流、连接池

简单来说,锁是信号量计数器为 1 的特例,而信号量是锁的泛化。

5、 信号量的实际应用场景

5.1 、连接池管理

在数据库或网络连接池中,信号量可以限制同时打开的连接数:

python 复制代码
import threading
import time

class ConnectionPool:
    def __init__(self, max_connections):
        self.semaphore = threading.Semaphore(max_connections)
        self.connections = []

    def get_connection(self):
        self.semaphore.acquire()
        # 模拟获取连接
        conn = f"Connection-{len(self.connections) + 1}"
        self.connections.append(conn)
        return conn

    def release_connection(self, conn):
        # 模拟释放连接
        self.connections.remove(conn)
        self.semaphore.release()

5.2、 限流控制

在 Web 爬虫或 API 调用中,信号量可以限制请求速率,避免对目标服务器造成过大压力:

python 复制代码
import threading
import time
import requests

# 限制同时最多 3 个请求
semaphore = threading.Semaphore(3)

def fetch_url(url):
    with semaphore:
        response = requests.get(url)
        print(f"Fetched {url}: {response.status_code}")
        return response

urls = ["https://example.com"] * 10
threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]

for t in threads:
    t.start()
for t in threads:
    t.join()

5.3 、生产者-消费者模式

信号量也可以用于实现生产者-消费者模式,控制缓冲区中元素的数量:

python 复制代码
import threading
import time
import random

# 缓冲区容量为 5
empty = threading.Semaphore(5)   # 空位数量
full = threading.Semaphore(0)    # 已占用位置数量
buffer = []

def producer():
    for i in range(10):
        empty.acquire()
        item = random.randint(1, 100)
        buffer.append(item)
        print(f"生产: {item}")
        full.release()
        time.sleep(0.5)

def consumer():
    for _ in range(10):
        full.acquire()
        item = buffer.pop(0)
        print(f"消费: {item}")
        empty.release()
        time.sleep(1)

t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start()
t2.start()
t1.join()
t2.join()

6、多进程中的信号量

multiprocessing 模块提供了与 threading 一致的信号量接口,用于多进程间的同步:

python 复制代码
from multiprocessing import Process, Semaphore
import time

def worker(semaphore, worker_id):
    with semaphore:
        print(f"Process {worker_id} 开始工作")
        time.sleep(2)
        print(f"Process {worker_id} 结束工作")

if __name__ == "__main__":
    semaphore = Semaphore(2)
    processes = []
    for i in range(5):
        p = Process(target=worker, args=(semaphore, i))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()

需要注意的是,多进程信号量在 Windows 和 Linux 上的行为略有差异,但基本用法保持一致。

7、 注意事项与常见陷阱

7.1 、死锁风险

信号量使用不当可能导致死锁。例如,线程在持有信号量时又去获取另一个信号量,可能造成循环等待。应尽量避免嵌套获取多个信号量。

7.2 、忘记释放

如果使用 acquire() / release() 手动管理,忘记释放会导致信号量计数器逐渐减少,最终所有线程都被阻塞。推荐始终使用 with 语句。

7.3 、信号量与 GIL

在 CPython 中,由于全局解释器锁(GIL)的存在,多线程无法真正并行执行 CPU 密集型任务。信号量主要用于 I/O 密集型任务的并发控制,对于 CPU 密集型任务,应使用多进程。

7.4、 性能开销

信号量的获取和释放涉及系统调用,频繁操作会带来一定的性能开销。在高频场景下,应尽量减少信号量的使用频率。

8、 总结

信号量是 Python 并发编程中一个强大而灵活的同步工具。通过本文的学习,我们掌握了:

  • 信号量的基本概念和工作原理;
  • 使用 threading.Semaphoremultiprocessing.Semaphore 控制并发数量;
  • 非阻塞获取、超时获取和有界信号量等高级用法;
  • 连接池、限流和生产者-消费者等实际应用场景;
  • 使用信号量时的注意事项和常见陷阱。

在实际开发中,合理使用信号量可以有效控制资源访问,提升程序的稳定性和性能。建议在编写并发代码时,优先使用 with 语句管理信号量,并时刻警惕死锁和资源泄漏的风险。

二、代码示例

python 复制代码
import threading
import time

# ====================== 1. 普通 Semaphore 基础演示 ======================
def demo_semaphore_basic():
    print("=" * 60)
    print("【1.普通 Semaphore(2):最多允许2个线程同时执行】")
    sem = threading.Semaphore(value=2)

    def worker(idx):
        # with 自动 acquire + 退出自动 release
        with sem:
            print(f"线程 {idx} 拿到信号量,进入临界区")
            time.sleep(1.0)  # 模拟耗时任务
            print(f"线程 {idx} 释放信号量,退出临界区")

    thread_list = []
    for i in range(5):
        t = threading.Thread(target=worker, args=(i,))
        thread_list.append(t)
        t.start()

    for t in thread_list:
        t.join()
    print("Semaphore 基础演示结束\n")


# ====================== 2. acquire 非阻塞 & timeout 超时特性 ======================
def demo_semaphore_timeout():
    print("=" * 60)
    print("【2.acquire(block=False) 非阻塞 / timeout 超时等待】")
    sem = threading.Semaphore(1)
    sem.acquire()  # 先占用信号量

    def try_get(id):
        # 非阻塞获取
        ok1 = sem.acquire(blocking=False)
        print(f"线程{id} 非阻塞获取结果: {ok1}")

        # 最多等待0.5秒
        ok2 = sem.acquire(timeout=0.5)
        print(f"线程{id} 等待0.5s获取结果: {ok2}")

    t = threading.Thread(target=try_get, args=(1,))
    t.start()
    t.join()
    sem.release()
    print("超时演示结束\n")


# ====================== 3. BoundedSemaphore 有界信号量(防止超额release) ======================
def demo_bounded_semaphore():
    print("=" * 60)
    print("【3.BoundedSemaphore:禁止无限制release】")
    bsem = threading.BoundedSemaphore(value=2)
    bsem.acquire()
    bsem.release()
    print("正常release一次成功")

    try:
        # 初始值=2,没有acquire就直接release,计数器溢出
        bsem.release()
        bsem.release()
        bsem.release()
    except ValueError as e:
        print(f"BoundedSemaphore 非法释放捕获异常: {e}")
    print("BoundedSemaphore演示结束\n")


# ====================== 4. 手动 acquire / try‑finally 安全释放(不使用with) ======================
def demo_manual_acquire():
    print("=" * 60)
    print("【4.手动 acquire / try‑finally 保证释放】")
    sem = threading.Semaphore(1)

    def task():
        acquired = False
        try:
            acquired = sem.acquire(timeout=1)
            if acquired:
                print("手动拿到信号量,执行业务逻辑")
                time.sleep(0.3)
            else:
                print("获取信号量超时")
        finally:
            if acquired:
                sem.release()
                print("finally中释放信号量")

    t = threading.Thread(target=task)
    t.start()
    t.join()
    print("手动管理信号量演示结束\n")


# ====================== 5. 信号量实现生产者消费者(和Queue对比) ======================
def demo_producer_consumer_sem():
    print("=" * 60)
    print("【5.信号量实现生产者-消费者模型】")
    empty = threading.Semaphore(3)   # 空位数量
    full = threading.Semaphore(0)    # 产品数量
    buffer = []
    lock = threading.Lock()

    def producer(num):
        for i in range(2):
            empty.acquire()
            with lock:
                item = f"data-{num}-{i}"
                buffer.append(item)
                print(f"生产者{num}生产: {item}, buffer={buffer}")
            full.release()
            time.sleep(0.2)

    def consumer(num):
        for _ in range(2):
            full.acquire()
            with lock:
                item = buffer.pop(0)
                print(f"消费者{num}消费: {item}, buffer={buffer}")
            empty.release()
            time.sleep(0.4)

    t1 = threading.Thread(target=producer, args=(1,))
    t2 = threading.Thread(target=consumer, args=(1,))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("生产者消费者信号量演示结束\n")


if __name__ == "__main__":
    demo_semaphore_basic()
    demo_semaphore_timeout()
    demo_bounded_semaphore()
    demo_manual_acquire()
    demo_producer_consumer_sem()
python 复制代码
D:\user\01417804\桌面\PythonProject\.venv\Scripts\python.exe D:\user\01417804\桌面\PythonProject\main.py 
============================================================
【1.普通 Semaphore(2):最多允许2个线程同时执行】
线程 0 拿到信号量,进入临界区
线程 1 拿到信号量,进入临界区
线程 0 释放信号量,退出临界区
线程 1 释放信号量,退出临界区线程 2 拿到信号量,进入临界区
线程 3 拿到信号量,进入临界区

线程 3 释放信号量,退出临界区线程 2 释放信号量,退出临界区

线程 4 拿到信号量,进入临界区
线程 4 释放信号量,退出临界区
Semaphore 基础演示结束

============================================================
【2.acquire(block=False) 非阻塞 / timeout 超时等待】
线程1 非阻塞获取结果: False
线程1 等待0.5s获取结果: False
超时演示结束

============================================================
【3.BoundedSemaphore:禁止无限制release】
正常release一次成功
BoundedSemaphore 非法释放捕获异常: Semaphore released too many times
BoundedSemaphore演示结束

============================================================
【4.手动 acquire / try‑finally 保证释放】
手动拿到信号量,执行业务逻辑
finally中释放信号量
手动管理信号量演示结束

============================================================
【5.信号量实现生产者-消费者模型】
生产者1生产: data-1-0, buffer=['data-1-0']
消费者1消费: data-1-0, buffer=[]
生产者1生产: data-1-1, buffer=['data-1-1']
消费者1消费: data-1-1, buffer=[]
生产者消费者信号量演示结束


进程已结束,退出代码为 0
相关推荐
新时代牛马1 小时前
cyclictest 毛刺从哪来?从ftrace、latencytop、perf到IRQ/调度延迟定位
android·开发语言·python·kotlin
Dxy12393102162 小时前
Python如何结合AI解决数据分析问题
人工智能·python·数据分析
IvanCodes2 小时前
Python 基础语法(七):推导式与常见遍历写法
开发语言·python
zhangzeyuaaa2 小时前
Python requests:raise_for_status() 与 4xx/5xx 异常层次详解
开发语言·python
泡海椒2 小时前
动态代理核心原理:JQuick-Java接口规则自动生成机制解析
java·开发语言·python
余槐i2 小时前
Firecrawl 实战:将网站转换为大模型可用数据
人工智能·python·工具·firecrawl
shirsl2 小时前
算法 Day 2 滑动窗口 + 栈 / 单调栈
开发语言·python·算法
步行cgn2 小时前
Spring 启动报错:BeanFactory not initialized 的原因与解决
java·python·spring
quantdash_cc3 小时前
股票历史数据为什么比实时行情更重要?回测结果失真的根源可能就在 K 线数据
开发语言·python·数据分析·量化交易·股票数据·quantdash