目录
- Overview
- python多线程
- 创建与启动多线程
- 多线程的同步
- 1. 互斥锁(`LOCK`)
- 2. 递归锁(`RLOCK`)
- 3. 信号量(`Semaphore`)
- 4. 条件变量(`Condition`)
- 5. 事件(`Event`)
- 6. 屏障(`Barrier`)
- 多线程局部数据
- 1. 什么是线程局部数据?
- 2. 如何使用 `threading.local()`?
- 3. 使用场景
- 4. 注意事项
- 全局解释器锁GIL
- 1. 为什么需要 GIL?
- 2. GIL 的影响
- 3. 绕过 GIL
- 4. 为什么 GIL 还在?
- 其他选用内容
- 1. 线程状态与生命周期
- 2. 线程优先级
- 3. 守护线程
- 4. 线程间通信
- 5. 线程池
- 6. 异常处理
Overview
多线程和多进程都是并发执行程序代码的技术,说白了,就是能够让我们同时执行多个任务。但是,多进程和多线程还是有很大区别的,首先,介绍下多进程和多线程的定义:
- 多线程 (Multithreading)
- 多线程是在同一个进程内部创建的多个执行线索或路径。
- 所有线程共享相同的内存空间和资源。
- 线程是轻量级的,因为它们共享相同的地址空间。
- 由于线程共享同一地址空间,因此线程之间的通信通常更快、更简单。
- 这也意味着必须使用同步原语(如锁、信号量等)来避免竞争条件和确保数据一致性。
- 适用于 I/O 密集型任务(如网络或磁盘 I/O),因为一个线程可以在等待 I/O 完成时让其他线程继续工作。
- 多进程 (Multiprocessing)
- 多进程是指在一个应用中同时运行多个独立的进程。
- 每个进程都有自己独立的内存空间和资源。
- 进程是重量级的,并且在进程之间进行上下文切换比在线程之间进行上下文切换要昂贵。
- 进程不共享内存空间,因此进程之间的通信(如通过管道、消息队列或套接字)可能相对较慢。
- 由于进程具有独立的内存空间,因此它们之间不太可能发生数据竞争或不一致。
- 适用于 CPU 密集型任务,特别是在多核处理器上,因为每个进程可以在不同的核心上独立运行。
其他博主也对多进程和多线程进行了更加形象化的介绍,具体可以参考多线程与多进程
python多线程
本节具体讲述python多线程的用法。主要用到python的threading
包.
创建与启动多线程
- 导入模块threading
import threading
- 定义线程要执行的函数
def print_numbers():
for i in range(10):
print(i)
- 创建线程对象,创建一个线程对象需要一个函数作为参数,这个函数是线程启动后要执行的函数。
t = threading.Thread(target=function, args, kwargs,)
function
: 想要在新线程中运行的函数。这应该是一个函数对象,而不是该函数的结果。例如,如果有一个函数 foo()
, 你应该传入 foo
而不是 foo()
。
args
: 这是一个元组,其中包含要传递给上述函数的参数。例如,如果函数需要两个参数,可以传递 (arg1, arg2)。 元组如果只有一个元素,一定要在最后加 ,
kwargs
: 这是一个可选的字典,其中包含要传递给函数的关键字参数。例如,如果函数是 foo(x, y=0)
,那么可以使用 kwargs 传递 { 'y': 1 }
。
- 启动线程
t.start()
- 等待线程完成(可选用),如果你希望主线程等待其他线程完成任务后再继续执行,可以使用
join()
方法。
t.join()
下面给出一个完成的示例:
import threading
import time
def print_numbers(x, delay=0.1):
for i in range(x):
print(str(i))
time.sleep(delay)
def print_letters(s:str, delay=0.2):
for letter in s:
print(letter)
time.sleep(delay)
# 创建线程
t1 = threading.Thread(target=print_numbers,args=(5,),kwargs={'delay': 0.1} )
t2 = threading.Thread(target=print_letters,args=('abcdefgh',),kwargs={'delay': 0.1} )
# 启动线程
t1.start()
t2.start()
# 等待线程完成
t1.join()
t2.join()
# 如果不使用t1.join(),t2.join(),则程序会不等t1, t2执行完就执行下面的语句
print("All threads are done!")
多线程的同步
在多线程环境中,同步是确保多个线程能够有序、正确地访问共享资源或执行特定的任务序列的机制。如果没有适当的同步,多个线程可能会同时修改共享资源,导致不可预测或不正确的结果。Python的多线程模块threading
提供了多种方法用于多线程的同步。
1. 互斥锁(LOCK
)
互斥锁(Mutex, 是 Mutual Exclusion 的缩写)是并发控制的一种基本同步原语。它的主要目的是为了防止多个线程同时访问某一段临界区代码。这段临界区代码通常涉及对共享资源(例如全局变量、文件等)的修改。
当一个线程想要进入临界区时,它必须首先尝试获取互斥锁。如果锁已被另一个线程持有,则该线程会被阻塞,直到持有锁的线程释放该锁。一旦线程获得锁,它就可以进入临界区,而其他线程则必须等待。
互斥锁的基本操作是:
- acquire(): 尝试获得锁。
- release(): 释放锁。
下面给出一个例子:
import threading
# 全局变量
counter = 0
# 创建一个互斥锁
lock = threading.Lock()
# 线程函数
def increment_counter():
global counter
for _ in range(2000000):
# 获取锁
lock.acquire()
counter += 1
# 释放锁
lock.release()
# 创建两个线程
t1 = threading.Thread(target=increment_counter)
t2 = threading.Thread(target=increment_counter)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter)
有一个全局变量 counter,多个线程会同时对其进行增加操作。为了确保每次只有一个线程修改 counter,需要使用互斥锁。在这个例子中,如果没有锁,counter 的最终值可能会小于 2000000(因为多个线程可能会同时读取和写入 counter,在t1读取时,可能t2已经加1,但是t1读的还是原来的数值,所以造成t2写入无效)。但是,由于使用了互斥锁,每次只有一个线程可以增加 counter,在counter被使用时,另外一个必须要等待counter所在的内存被释放才能加1(for 循环在获取lock时被阻塞,暂停),所以最终的结果应该是正确的2000000。
2. 递归锁(RLOCK
)
递归锁(Recursive Lock)是一种特殊类型的锁,允许同一线程多次请求同一锁,而不会造成死锁。这与标准的互斥锁不同,因为标准锁只允许线程单次获得它。如果同一个线程试图再次获得(或重新获得)标准的互斥锁,它会被阻塞,即使是它自己已经持有这个锁,这会导致死锁。
递归锁在内部维护了一个计数器,用于记录锁已经被同一线程获得的次数。当线程首次获得锁时,计数器设为1。如果同一线程再次获得锁,计数器增加而不是阻塞线程。当线程释放锁时,计数器减少。只有当计数器回到0时,锁才真正释放,其他线程才能获得它。
递归锁在以下情况下特别有用:
- 递归函数:当函数是递归的,并且在每次递归调用中都需要获得锁。
- 复杂的操作:当你在一系列函数或方法调用中需要持有锁,并且其中一些函数可能会被多次调用。
下面给出一个示例:
import threading
# 创建递归锁
rlock = threading.RLock()
def recursive_function(n):
if n <= 0:
return
rlock.acquire()
print(f"Level {n} acquired lock")
recursive_function(n-1)
print(f"Level {n} releasing lock")
rlock.release()
recursive_function(3)
上述示例给出了一个递归函数,该函数需要在每次调用时获得锁。recursive_function
递归地调用自己,并在每次调用中都试图获得锁。使用标准的互斥锁会导致死锁,但使用递归锁可以成功地运行。
虽然递归锁为某些场景提供了方便,但也有其局限性和风险。例如,过度依赖递归锁可能会掩盖代码的不良设计或结构问题。此外,每次获得或释放递归锁都会有一些性能开销,因为它需要维护一个计数器。因此,当可能的话,最好避免在非递归场景中使用递归锁。
3. 信号量(Semaphore
)
信号量(Semaphore)是一个高级同步原语,用于控制对共享资源的并发访问。它通常用于限制可以同时访问某些资源或执行特定代码段的线程数。信号量有时可以被看作是一个可以持有多个 “许可” 或 “票” 的锁。
信号量有两个主要操作:
-
acquire()
: 尝试获取一个许可。如果没有可用的许可,调用此方法的线程会被阻塞。 -
release()
: 释放一个许可,使其他等待许可的线程可以获取它。
信号量的工作方式与计数器类似。当你创建一个信号量时,你可以定义其初始的许可数量。每次调用acquire()
会减少许可的数量,每次调用release()
会增加许可的数量。
示例:
考虑一个场景,我们有一个只能同时处理三个任务的资源池。为了确保不超过三个线程同时访问资源,我们可以使用信号量:
import threading
import time
# 创建一个信号量,允许最多三个线程同时访问
semaphore = threading.Semaphore(3)
def task(thread_id):
print(f"Thread-{thread_id} is waiting for a resource")
semaphore.acquire()
print(f"Thread-{thread_id} acquired a resource")
time.sleep(2) # 模拟资源使用
print(f"Thread-{thread_id} released a resource")
semaphore.release()
threads = []
# 创建五个线程,尝试访问资源
for i in range(5):
thread = threading.Thread(target=task, args=(i,))
thread.start()
threads.append(thread)
# 确保所有子线程都完成,再进行下一步,结束主线程
for thread in threads:
thread.join()
在多线程编程中,thread.join()
方法的作用是等待直到启动的线程终止。换句话说,调用此方法的线程(通常是主线程)会被阻塞,直到被 join()
方法调用的线程完成执行,上述代码的输出为:
Thread-0 is waiting for a resource
Thread-0 acquired a resource
Thread-1 is waiting for a resource
Thread-1 acquired a resource
Thread-2 is waiting for a resource
Thread-2 acquired a resource
Thread-3 is waiting for a resource
Thread-4 is waiting for a resource
Thread-2 released a resource
Thread-1 released a resource
Thread-0 released a resource
Thread-3 acquired a resource
Thread-4 acquired a resource
Thread-3 released a resource
Thread-4 released a resource
4. 条件变量(Condition
)
条件变量是一种同步原语,用于允许线程等待特定条件的满足。条件变量通常与互斥锁(或递归锁)结合使用,以保护共享数据,并基于该数据的状态来同步线程的执行。
条件变量有以下几个主要的操作方法:
wait()
:
- 当一个线程调用此方法时,它会释放与条件变量关联的锁,并使线程进入睡眠状态,直到另一个线程调用
notify()
或notify_all()
方法。 - 一旦被通知,线程会重新尝试获得锁,并继续从
wait()
调用处执行。
notify()
:
- 唤醒一个正在等待该条件变量的线程(如果有的话)。
notify_all()
或notifyAll()
:
- 唤醒所有正在等待该条件变量的线程。
with
语句:
- 条件变量通常与 Python 的
with
语句结合使用,以确保关联的锁被正确地获取和释放。
条件变量常用于以下场景:
- 当一个线程需要等待一个特定的条件成立(如队列非空)时。
- 当一个线程改变了某个条件,并需要通知其他线程这一改变时。
示例
考虑一个简单的生产者-消费者问题,其中生产者生产项目并将其放入队列中,而消费者从队列中获取项目:
import threading
import time
import random
# 使用 list 模拟一个简单的队列
queue = []
MAX_ITEMS = 5
condition = threading.Condition()
def producer():
for _ in range(10):
time.sleep(random.uniform(0.1, 0.7))
with condition:
while len(queue) >= MAX_ITEMS:
condition.wait() # 等待队列非满
item = f"item-{random.randint(1, 100)}"
queue.append(item)
print(f"Produced {item}")
condition.notify_all() # 通知消费者
def consumer():
for _ in range(10):
time.sleep(random.uniform(0.2, 0.6))
with condition:
while not queue:
condition.wait() # 等待队列非空
item = queue.pop(0)
print(f"Consumed {item}")
condition.notify_all() # 通知生产者
p = threading.Thread(target=producer)
c = threading.Thread(target=consumer)
p.start()
c.start()
p.join()
c.join()
在上述示例中,生产者线程生产项目并将其添加到队列中,而消费者线程从队列中消费项目。条件变量 condition
用于确保生产者在队列已满时等待,而消费者在队列为空时等待。当生产者添加一个项目或消费者消费一个项目时,它们会使用 notify_all()
通知其他线程。
- 注意事项
- 正确的锁管理:使用条件变量时,确保始终在关联的锁的保护下访问共享数据,并在调用
wait()
、notify()
或notify_all()
时持有该锁。 - 避免虚假唤醒:
wait()
有时可能会因为各种原因(不一定是notify()
调用)而被唤醒,这称为虚假唤醒。为了避免这种情况,通常建议使用循环来检查等待的条件,如上面的例子中所示。 - 选择正确的通知方法:如果你知道只需要唤醒一个线程,使用
notify()
;如果你认为可能有多个线程在等待,或者你不确定,使用notify_all()
。
另外, 在Python的threading
模块中,条件变量(Condition
)提供了两个通知方法:notify()
和 notify_all()
。
notify(n=1)
:
- 这个方法用于唤醒正在等待该条件变量的一个或多个线程。参数
n
表示要唤醒的线程数,默认值为1,即默认唤醒一个线程。 - 但是,请注意,哪个线程会被唤醒是不确定的。你不能指定唤醒特定的线程;它取决于线程调度和其他因素。
notify_all()
:
- 这个方法用于唤醒所有正在等待该条件变量的线程。
如果想唤醒特定的线程,需要使用更复杂的同步方法或设计模式。标准的条件变量并不提供直接的机制来选择哪个线程应该被唤醒。
在实践中,如果需要这样的精确控制,通常的做法是为每种线程或任务类型使用不同的条件变量,这样就可以更精确地控制通知的发送和接收。但这也增加了代码的复杂性,因此需要权衡。
5. 事件(Event
)
事件(Event
)是Python的threading
模块中提供的一个同步原语。它是一个简单的、线程间的通信机制,允许线程等待某个事件的发生,或者通知其他线程事件已经发生。事件对象管理一个内部标记,可以被设置或重置,这个标记用于表示是否发生了某个事件。
事件的主要方法有:
set()
:
- 将内部标记设置为
True
。一旦标记被设置,所有正在等待这个事件的线程都会被唤醒。
clear()
:
- 将内部标记重置为
False
。使用此方法可以明确地重置事件。
wait(timeout=None)
:
- 如果内部标记已经被设置,则立即返回。否则,线程将被阻塞,直到事件被设置或可选的超时时间到达。
is_set()
:
- 返回事件的内部标记状态。如果事件已经被设置,返回
True
;否则返回False
。
示例
考虑一个场景,其中一个线程负责计算,另一个线程负责显示结果。但是,显示线程需要等待计算线程完成工作:
import threading
import time
event = threading.Event()
result = None
def calculator():
global result
time.sleep(2) # 模拟一些计算
result = "Calculation result"
event.set() # 通知结果已经准备好
def displayer():
print("Waiting for calculation to complete...")
event.wait() # 等待计算完成
print(f"Displaying: {result}")
t1 = threading.Thread(target=calculator)
t2 = threading.Thread(target=displayer)
t1.start()
t2.start()
t1.join()
t2.join()
在上面的示例中,displayer
线程使用 event.wait()
等待事件被设置。一旦 calculator
线程完成其工作并调用 event.set()
,displayer
线程将被唤醒并继续执行。
事件通常用于以下场景:
- 当一个线程需要等待另一个线程完成某项任务时。
- 当多个线程需要在某个条件下同步它们的操作时。
注意事项
- 与其他同步原语一样,当使用事件时,确保正确地设置和重置事件以避免死锁或不必要的等待。
- 事件对象不同于条件变量或信号量,它不关联任何特定的锁,也不关心多少线程等待事件。当事件被设置时,所有等待的线程都会被唤醒。
6. 屏障(Barrier
)
屏障(Barrier)是一个同步原语,用于同步固定数量的线程,使它们都达到一个公共的同步点(或屏障点),然后再一起继续执行。屏障的主要目的是确保在所有参与线程继续执行之前,每个线程都达到了预定的同步点。
Python的threading
模块提供了一个Barrier
类,用于创建并管理屏障。
主要方法:
wait()
:
- 当一个线程调用此方法时,它将被阻塞,直到所有其他线程也都达到了屏障点。
- 一旦所有线程都调用了
wait()
,屏障就会被释放,所有线程都可以继续执行。
reset()
:
- 如果某些原因导致某个线程知道它不会到达屏障点(例如,因为发生了异常),那么它可以调用
reset()
方法。 - 这将导致任何正在等待屏障的线程收到一个
BrokenBarrierError
异常。
abort()
:
- 与
reset()
类似,但在调用abort()
后,屏障将处于“已损坏”的状态,任何后续的wait()
调用都会立即引发BrokenBarrierError
。
parties
:
- 属性,表示应该等待的线程数量。
示例:
假设我们有三个线程,它们都需要在继续执行前完成一些初始化工作:
import threading
import time
# 创建一个屏障,需要3个线程到达屏障点
barrier = threading.Barrier(3)
def worker(num):
print(f"Worker-{num} is starting initialization...")
time.sleep(num) # 模拟初始化工作
print(f"Worker-{num} is ready!")
barrier.wait() # 等待其他线程
print(f"Worker-{num} is working after the barrier.")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
在上述代码中,三个工作线程都会在继续执行前完成一些初始化工作。一旦一个线程完成了初始化,它会等待其他线程到达屏障点。当所有线程都到达屏障点时,它们都会继续执行。
使用场景:
屏障是多线程编程中的一个有用工具,特别是当多个线程需要在开始某个任务之前进行一些并行的初始化工作时。例如,在模拟或并行计算中,可能有多个线程需要初始化其数据结构或资源,然后它们可以同时开始计算或执行任务。
多线程局部数据
当我们在 Python 中使用多线程时,经常需要确保每个线程都有自己的独立数据副本,以避免数据冲突。为了解决这个问题,Python 提供了 threading.local()
来创建线程局部数据。
1. 什么是线程局部数据?
线程局部数据是每个线程特有的数据副本。这意味着,即使多个线程都访问相同的变量,但由于它们都在各自的线程局部数据中,所以这些变量对于每个线程来说都是独立的。
2. 如何使用 threading.local()
?
可以使用 threading.local()
创建一个线程局部数据对象。然后,可以像访问普通对象的属性那样访问它的属性,但这些属性在每个线程中都是独立的。
import threading
# 创建一个线程局部数据对象
local_data = threading.local()
def display_data():
try:
val = local_data.value
except AttributeError:
print("No value yet")
else:
print("Value:", val)
def worker(number):
print(f"Thread {number}: Starting")
local_data.value = number # 每个线程都有自己的 `value` 属性副本
display_data()
threads = []
for i in range(5):
thread = threading.Thread(target=worker, args=(i,))
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
在上面的例子中,尽管每个线程都使用了相同的 local_data
对象,但由于 value
是线程局部的,所以它们都有自己的 value
属性副本。
3. 使用场景
线程局部数据在以下场景中特别有用:
- 数据库连接:当多个线程需要访问数据库时,可以使用线程局部数据为每个线程存储其自己的数据库连接。
- 请求上下文:在 web 应用程序中,可以使用线程局部数据存储每个请求的特定数据,如当前用户或请求ID。
4. 注意事项
尽管 threading.local()
很有用,但也需要注意以下事项:
- 不应过度使用:过度使用线程局部数据会使代码变得难以理解和维护。
- 垃圾收集:当线程结束时,其线程局部数据不会被立即释放。这可能导致内存泄漏,特别是在频繁创建和销毁线程的情况下。
总之,threading.local()
是一个强大的工具,但也需要谨慎使用。
全局解释器锁GIL
全局解释器锁(Global Interpreter Lock,简称 GIL)是 CPython 解释器中的一个机制,用于同步对 Python 对象的访问。这是因为 CPython 的内存管理不是线程安全的。GIL 保证了在任何时刻,只有一个线程在执行 Python 字节码。
以下是 GIL 的一些关键概念和影响:
1. 为什么需要 GIL?
CPython 的内存管理并没有设计成线程安全的。为了避免并发访问造成的数据不一致和其它问题,Python 设计者引入了 GIL,确保在任何给定的时间,只有一个线程可以执行 Python 字节码。这简化了 CPython 的设计,并且提高了在单线程情况下的性能。
2. GIL 的影响
GIL 主要影响到 CPU-bound 程序:
- I/O-bound 程序:对于那些主要受 I/O(如网络、文件 I/O)限制的程序,GIL 的影响相对较小,因为线程大部分时间都在等待 I/O。在这种情况下,使用 Python 的多线程仍然是有益的。
- CPU-bound 程序:对于计算密集型的程序,GIL 会阻止多线程充分利用多核 CPU。即使在多核机器上,由于 GIL 的存在,多线程的 CPU-bound Python 程序通常不能实现真正的并行执行。
3. 绕过 GIL
有几种方法可以绕过 GIL 的限制:
- 使用多进程:Python 的
multiprocessing
模块允许创建多个进程。每个进程有自己的 Python 解释器和内存空间,因此 GIL 不会在进程之间产生影响。这使得 CPU-bound 程序可以在多核机器上实现真正的并行执行。 - 使用其他 Python 解释器:Jython(基于 Java 的 Python 解释器)和 IronPython(基于 .NET 的 Python 解释器)都没有 GIL。但它们可能不完全支持某些 Python 库。
- 使用 C 扩展:可以使用 Cython 或 C API 编写 C 扩展,这些扩展可以在 C 代码中释放 GIL。这样,这些扩展的计算密集部分可以在没有 GIL 限制的情况下并行运行。
4. 为什么 GIL 还在?
尽管 GIL 有许多已知的限制,但它仍然存在于 CPython 中,原因如下:
- 简单性:GIL 提供了一个简单的并发模型,并简化了解释器的设计。
- 历史原因:移除 GIL 需要大量的重新设计和重写,这是一个巨大的工程任务。
- 单线程性能:GIL 实际上可以提高单线程程序的性能,因为它避免了添加额外的锁定机制。
总的来说,GIL 是 CPython 中的一个重要特性,但也是一个双刃剑。对于需要真正并行执行的 CPU-bound 程序,GIL 是一个限制,但对于许多其他应用程序,它并不构成实际问题。
其他选用内容
Python 的多线程涵盖了很多知识点。除了上述提到的内容之外,以下是一些其他重要的多线程概念, 可以根据具体任务需求选学选用。
1. 线程状态与生命周期
线程在其生命周期中会经历多个状态,如:新建、就绪、运行、阻塞和结束。了解这些状态及其之间的转换有助于更好地理解线程的行为。
2. 线程优先级
虽然 Python 的 threading
模块不直接支持线程优先级的设置,但在某些系统上,线程的优先级可以通过操作系统或其他方法进行调整。
3. 守护线程
守护线程是一个在程序主线程退出时自动结束的线程。它常常用于后台任务,如日志记录、监视等。
import threading
import time
def daemon_thread():
while True:
time.sleep(1)
print("Daemon thread is running...")
d_thread = threading.Thread(target=daemon_thread)
d_thread.setDaemon(True)
d_thread.start()
time.sleep(3)
print("Main thread is done!")
在上述代码中,守护线程会一直运行,但当主线程结束后,守护线程也会立即结束。
4. 线程间通信
线程之间通常使用队列 (queue.Queue
) 进行通信。这提供了一个线程安全的方法来传递消息或数据。
import threading
import queue
def worker(q):
while True:
item = q.get()
if item is None: # 结束标志
break
print(f"Processing {item}")
q = queue.Queue()
thread = threading.Thread(target=worker, args=(q,))
thread.start()
for i in range(5):
q.put(i)
q.put(None) # 发送结束信号
thread.join()
5. 线程池
线程池是预先创建的线程集合,可以用来执行任务。使用线程池可以避免频繁地创建和销毁线程,从而提高性能。Python 的 concurrent.futures.ThreadPoolExecutor
提供了这一功能。
from concurrent.futures import ThreadPoolExecutor
def task(n):
return n * n
with ThreadPoolExecutor(max_workers=4) as executor:
results = list(executor.map(task, range(10)))
print(results)
6. 异常处理
在多线程程序中,线程中的未捕获异常不会导致整个程序的终止,但会导致该线程的终止。
import threading
def thread_with_exception():
raise Exception("This is an error!")
thread = threading.Thread(target=thread_with_exception)
thread.start()
thread.join()
print("Main thread continues running.")
上面的代码中,尽管子线程中抛出了异常,主线程仍然继续执行。
这只是多线程的一些关键概念。多线程编程是一个复杂的领域,涉及的内容非常丰富,需要不断地实践和学习。具体可以参考官网Python多线程