MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python多线程与异步编程的关系

2024-01-235.7k 阅读

Python多线程基础

多线程概念

在计算机编程领域,线程是程序执行流的最小单元。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、文件描述符等。多线程编程允许在同一个程序中同时执行多个线程,从而实现并发处理任务。例如,在一个图形用户界面(GUI)应用程序中,主线程负责处理用户界面的绘制和响应,而其他线程可以用于执行后台任务,如数据加载或网络请求,这样可以避免用户界面在处理这些耗时操作时出现卡顿。

在Python中,通过threading模块来支持多线程编程。创建一个简单的线程示例如下:

import threading


def print_numbers():
    for i in range(1, 6):
        print(f"线程1: {i}")


def print_letters():
    for letter in 'abcde':
        print(f"线程2: {letter}")


if __name__ == '__main__':
    thread1 = threading.Thread(target=print_numbers)
    thread2 = threading.Thread(target=print_letters)

    thread1.start()
    thread2.start()

    thread1.join()
    thread2.join()

在上述代码中,我们创建了两个线程thread1thread2,分别执行print_numbersprint_letters函数。start()方法用于启动线程,join()方法用于等待线程执行完毕。

线程的生命周期

线程具有以下几个状态,构成了其生命周期:

  1. 新建(New):当创建一个Thread对象时,线程处于新建状态。例如thread = threading.Thread(target=func),此时线程尚未启动。
  2. 就绪(Runnable):调用start()方法后,线程进入就绪状态。在这个状态下,线程等待CPU调度,一旦获得CPU时间片,就可以开始执行。
  3. 运行(Running):线程获取到CPU时间片,正在执行其run()方法中的代码。在Python中,由于全局解释器锁(GIL)的存在,同一时刻只有一个线程能在CPU上运行,即使在多核CPU环境下也是如此。
  4. 阻塞(Blocked):线程在执行过程中,可能会因为某些原因进入阻塞状态,如等待I/O操作完成、等待获取锁等。例如,当线程执行文件读取操作或者调用time.sleep()函数时,就会进入阻塞状态。在阻塞状态下,线程不占用CPU资源。
  5. 死亡(Dead):线程执行完run()方法中的所有代码,或者遇到return语句、未处理的异常等情况,线程就会结束,进入死亡状态。一旦线程进入死亡状态,就不能再重新启动。

全局解释器锁(GIL)

Python的多线程有一个重要的特性就是全局解释器锁(GIL)。GIL是一个互斥锁,它确保在任何时刻,只有一个线程能够在Python解释器中执行字节码。这意味着,即使在多核CPU环境下,Python的多线程也无法真正利用多核的优势来实现并行计算。

对于CPU密集型任务,由于GIL的存在,多线程可能并不会带来性能提升,甚至可能因为线程切换的开销而导致性能下降。例如,下面的计算密集型任务:

import threading
import time


def cpu_bound_task():
    result = 0
    for i in range(100000000):
        result += i
    return result


if __name__ == '__main__':
    start_time = time.time()
    threads = []
    for _ in range(4):
        thread = threading.Thread(target=cpu_bound_task)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"多线程执行时间: {end_time - start_time} 秒")

    start_time = time.time()
    for _ in range(4):
        cpu_bound_task()
    end_time = time.time()
    print(f"单线程执行时间: {end_time - start_time} 秒")

在上述代码中,我们对比了多线程和单线程执行CPU密集型任务的时间。你会发现,多线程执行的时间并没有比单线程有明显优势,甚至可能更慢。

然而,对于I/O密集型任务,多线程仍然是有意义的。因为当一个线程在等待I/O操作完成时(如网络请求、文件读写等),它会释放GIL,其他线程就可以利用这个时间片来执行。例如:

import threading
import time


def io_bound_task():
    time.sleep(2)
    print("I/O任务完成")


if __name__ == '__main__':
    start_time = time.time()
    threads = []
    for _ in range(4):
        thread = threading.Thread(target=io_bound_task)
        threads.append(thread)
        thread.start()

    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"多线程执行时间: {end_time - start_time} 秒")

    start_time = time.time()
    for _ in range(4):
        io_bound_task()
    end_time = time.time()
    print(f"单线程执行时间: {end_time - start_time} 秒")

在这个I/O密集型任务的例子中,多线程可以显著减少总的执行时间。

异步编程基础

异步编程概念

异步编程是一种编程模型,它允许程序在执行某些操作时不阻塞主线程,从而提高程序的响应性和效率。在传统的同步编程中,程序按照顺序依次执行各个语句,当遇到一个耗时操作(如I/O操作)时,主线程会被阻塞,直到该操作完成,其他任务才能继续执行。而异步编程通过事件循环、回调函数、协程等机制,使得程序在等待耗时操作完成的同时,可以继续执行其他任务。

例如,在一个网络爬虫程序中,当发送一个HTTP请求后,需要等待服务器响应。在同步编程中,主线程会一直等待响应返回,期间无法进行其他操作。而异步编程可以在等待响应的同时,继续发送其他请求或者处理已经接收到的响应数据。

事件循环

事件循环是异步编程中的核心概念。它是一个无限循环,不断地检查是否有新的事件(如I/O操作完成、定时器到期等)发生,并将这些事件分配给相应的回调函数或协程来处理。

在Python中,asyncio库提供了事件循环的实现。下面是一个简单的使用asyncio的事件循环示例:

import asyncio


async def coroutine_function():
    print("开始执行协程")
    await asyncio.sleep(2)
    print("协程执行完毕")


if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        loop.run_until_complete(coroutine_function())
    finally:
        loop.close()

在上述代码中,我们通过asyncio.get_event_loop()获取事件循环对象loop,然后使用loop.run_until_complete()方法将协程coroutine_function提交到事件循环中执行。await asyncio.sleep(2)语句表示暂停当前协程的执行,将控制权交回给事件循环,事件循环可以在这2秒内执行其他任务。2秒后,协程恢复执行。

回调函数

回调函数是异步编程中常用的一种机制。它是一个函数,作为参数传递给另一个函数,当某个操作完成时,被调用的函数会调用这个回调函数来处理结果。

例如,在处理网络请求时,可以将一个回调函数传递给发送请求的函数,当请求完成时,发送请求的函数会调用这个回调函数,并将响应数据作为参数传递给它。以下是一个简单的模拟回调函数的示例:

def callback_function(result):
    print(f"回调函数被调用,结果是: {result}")


def async_operation(callback):
    print("开始异步操作")
    # 模拟异步操作,这里用time.sleep代替实际的异步操作
    import time
    time.sleep(2)
    result = 42
    callback(result)


if __name__ == '__main__':
    async_operation(callback_function)

在上述代码中,async_operation函数接受一个回调函数callback作为参数。在模拟的异步操作完成后,调用callback函数并传递结果。

协程

协程是Python异步编程的重要组成部分。它是一种特殊的函数,可以在执行过程中暂停和恢复,类似于生成器。与普通函数不同,协程函数使用async def关键字定义,并且可以使用await关键字暂停执行,等待一个可等待对象(如另一个协程、Future对象等)完成。

例如:

async def coroutine1():
    print("协程1开始")
    await asyncio.sleep(1)
    print("协程1结束")


async def coroutine2():
    print("协程2开始")
    await asyncio.sleep(1)
    print("协程2结束")


async def main():
    await asyncio.gather(coroutine1(), coroutine2())


if __name__ == '__main__':
    asyncio.run(main())

在上述代码中,coroutine1coroutine2是两个协程函数。main函数中使用asyncio.gather来并发运行这两个协程。asyncio.run用于运行异步函数,并自动管理事件循环的生命周期。

Python多线程与异步编程的比较

适用场景

  1. 多线程适用场景
    • I/O密集型任务:如文件读写、网络请求等。由于I/O操作通常会花费大量时间等待数据传输,在等待过程中线程会释放GIL,其他线程可以利用这段时间执行,从而提高整体效率。例如,一个同时需要从多个文件中读取数据并进行处理的程序,使用多线程可以在一个线程等待文件读取时,让其他线程继续处理已读取的数据或读取其他文件。
    • 简单的并发控制:当需要同时执行多个相对独立且简单的任务,并且对资源共享和同步要求不高时,多线程是一个简单的选择。例如,在一个监控程序中,需要同时监控多个系统指标(如CPU使用率、内存使用率等),每个监控任务可以作为一个线程独立运行。
  2. 异步编程适用场景
    • 高度并发的I/O操作:在处理大量I/O操作,如网络爬虫中需要同时发起大量HTTP请求,或者在一个网络服务器中需要处理大量客户端连接时,异步编程能够更高效地利用资源。因为异步编程通过事件循环和协程,在I/O操作等待时不会阻塞线程,而是切换到其他可执行的任务,大大提高了系统的并发处理能力。
    • 对性能要求极高的I/O场景:例如在实时数据处理系统中,需要快速响应并处理来自多个数据源的实时数据,异步编程可以避免线程切换的开销,实现更高效的处理。

资源消耗

  1. 多线程资源消耗
    • 内存消耗:每个线程都需要占用一定的内存空间,用于存储线程的栈空间、寄存器状态等。随着线程数量的增加,内存消耗会显著上升。例如,在一个32位系统中,每个线程的默认栈大小可能是1MB,当创建大量线程时,内存很快就会被耗尽。
    • 上下文切换开销:当CPU在多个线程之间切换时,需要保存和恢复线程的上下文信息(如寄存器的值、程序计数器的值等),这会带来一定的开销。如果线程切换过于频繁,这种开销会对性能产生较大影响。特别是在CPU密集型任务中,由于GIL的存在,线程频繁切换不仅不能提高效率,反而会增加上下文切换的开销。
  2. 异步编程资源消耗
    • 内存消耗:异步编程主要基于协程,协程相比线程占用的资源要少得多。协程没有独立的栈空间,它们共享主线程的栈,只需要保存少量的状态信息。因此,在处理大量并发任务时,异步编程的内存消耗相对较低。
    • 上下文切换开销:异步编程中的上下文切换主要是在协程之间进行,由于协程是在用户态实现的,上下文切换的开销比线程在操作系统内核态的上下文切换要小得多。而且,异步编程通过事件循环的调度机制,能够更合理地安排任务执行,减少不必要的上下文切换。

并发模型

  1. 多线程并发模型
    • 基于线程的并发:多线程通过创建多个线程来实现并发执行任务。每个线程都有自己独立的执行路径,可以同时运行不同的代码块。例如,在一个多媒体处理程序中,一个线程可以负责音频播放,另一个线程负责视频解码,通过多线程实现音频和视频的同步播放。
    • 共享资源与同步问题:由于多个线程共享进程的资源,如内存空间、文件描述符等,这就带来了共享资源的同步问题。如果多个线程同时访问和修改共享资源,可能会导致数据不一致等问题。例如,多个线程同时对一个全局变量进行累加操作,可能会因为线程执行顺序的不确定性而得到错误的结果。为了解决这些问题,需要使用锁、信号量等同步机制来保证线程安全。
  2. 异步编程并发模型
    • 基于事件循环的并发:异步编程通过事件循环来管理和调度任务。事件循环不断地检查事件队列,当有事件发生(如I/O操作完成)时,将相应的任务(协程)加入到可执行队列中执行。例如,在一个网络服务器中,事件循环可以同时处理多个客户端的连接请求,当某个客户端有数据到达时,事件循环将处理该客户端数据的协程加入到执行队列中。
    • 无共享状态或轻共享状态:异步编程通常采用无共享状态或轻共享状态的设计模式。由于协程之间通过事件循环调度,它们之间的数据共享相对较少,这在一定程度上避免了多线程中复杂的同步问题。例如,在一个异步的网络爬虫中,每个协程负责处理一个网页的抓取和解析,协程之间不需要共享大量的数据,只需要将结果汇总到一个地方即可。

代码复杂度

  1. 多线程代码复杂度
    • 同步机制的复杂性:为了保证线程安全,在多线程编程中需要使用各种同步机制,如锁、条件变量、信号量等。这些同步机制的使用增加了代码的复杂性。例如,在使用锁时,需要小心地控制锁的获取和释放时机,避免死锁的发生。如果多个线程相互等待对方释放锁,就会导致死锁,使得程序无法继续执行。
    • 调试困难:由于线程执行的不确定性,多线程程序的调试相对困难。当出现问题时,很难确定是哪个线程在什么时机出现了错误。例如,在一个多线程的数据库操作程序中,可能会出现数据不一致的问题,但由于线程执行顺序的随机性,很难复现问题并定位错误。
  2. 异步编程代码复杂度
    • 异步语法的学习成本:异步编程使用了asyncawait等新的语法,对于不熟悉这些语法的开发者来说,有一定的学习成本。特别是在处理复杂的异步逻辑时,如多个协程之间的嵌套调用、错误处理等,需要对异步编程的原理和语法有深入的理解。
    • 回调地狱(曾有):在早期的异步编程中,大量使用回调函数来处理异步操作的结果,这可能会导致代码出现回调地狱的问题,即代码嵌套层次过多,可读性和维护性变差。例如:
def step1(callback):
    # 模拟异步操作
    import time
    time.sleep(1)
    result1 = "结果1"
    callback(result1)


def step2(result1, callback):
    # 模拟异步操作
    import time
    time.sleep(1)
    result2 = result1 + " 处理后"
    callback(result2)


def step3(result2, callback):
    # 模拟异步操作
    import time
    time.sleep(1)
    result3 = result2 + " 再次处理"
    callback(result3)


def final_callback(result3):
    print(result3)


step1(lambda result1: step2(result1, lambda result2: step3(result2, final_callback)))

在上述代码中,由于回调函数的嵌套,代码变得非常难以阅读和维护。不过,Python引入asyncawait语法后,有效地解决了回调地狱的问题,使得异步代码更加简洁和可读。

Python多线程与异步编程的结合使用

何时结合使用

  1. 混合I/O和CPU密集型任务:在实际应用中,很多任务既包含I/O操作,也包含CPU密集型计算。例如,在一个数据分析程序中,可能需要从文件中读取大量数据(I/O操作),然后对这些数据进行复杂的计算(CPU密集型任务)。在这种情况下,可以结合多线程和异步编程。使用异步编程处理I/O操作,利用其高效的I/O并发能力;使用多线程处理CPU密集型计算,虽然由于GIL的存在不能完全实现并行计算,但在一定程度上可以利用多核CPU的资源。
  2. 利用现有多线程库与异步框架:有时候项目中已经使用了一些成熟的多线程库来处理某些功能,同时又希望引入异步编程来提高整体的I/O性能。例如,一个基于Django的Web应用,Django内部使用多线程来处理请求,而在处理一些外部API调用等I/O操作时,可以引入异步编程来提高响应速度。在这种情况下,可以在多线程的环境中合理地嵌入异步代码,充分利用两者的优势。

结合方式

  1. 在多线程中使用异步代码:可以在多线程的函数中调用异步函数,通过创建事件循环来运行异步任务。例如:
import threading
import asyncio


async def async_task():
    print("异步任务开始")
    await asyncio.sleep(2)
    print("异步任务结束")


def thread_function():
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    try:
        loop.run_until_complete(async_task())
    finally:
        loop.close()


if __name__ == '__main__':
    thread = threading.Thread(target=thread_function)
    thread.start()
    thread.join()

在上述代码中,我们在thread_function函数(线程执行的函数)中创建了一个新的事件循环,并运行了一个异步任务async_task。这样可以在多线程的环境中利用异步编程的优势来处理I/O操作。

  1. 在异步代码中调用多线程函数:有时候,可能需要在异步代码中调用一些不支持异步的多线程函数。例如,一些旧的库函数或者需要利用多核CPU资源的计算函数。在这种情况下,可以使用run_in_executor方法将多线程函数提交到线程池或进程池中执行,从而在异步环境中运行这些函数。
import asyncio
import concurrent.futures
import time


def cpu_bound_function():
    print("CPU密集型函数开始")
    time.sleep(2)
    print("CPU密集型函数结束")
    return 42


async def main():
    with concurrent.futures.ThreadPoolExecutor() as executor:
        result = await asyncio.get_running_loop().run_in_executor(executor, cpu_bound_function)
        print(f"结果是: {result}")


if __name__ == '__main__':
    asyncio.run(main())

在上述代码中,cpu_bound_function是一个CPU密集型的多线程函数(这里简单模拟),在main异步函数中,我们使用run_in_executor方法将其提交到线程池中执行,并通过await等待其执行结果。这样就实现了在异步代码中调用多线程函数。

注意事项

  1. 资源管理:当结合使用多线程和异步编程时,需要注意资源的合理管理。例如,在多线程中创建事件循环时,要确保每个线程的事件循环独立,避免资源冲突。同时,在异步代码中使用线程池或进程池时,要注意控制池的大小,避免过多的线程或进程导致系统资源耗尽。
  2. 异常处理:由于多线程和异步编程都有各自的异常处理机制,在结合使用时需要特别注意异常的传递和处理。例如,在多线程中运行异步任务时,如果异步任务抛出异常,需要确保异常能够正确地传递到线程的调用者,并进行合适的处理。同样,在异步代码中调用多线程函数时,如果多线程函数抛出异常,也需要在异步环境中进行正确的捕获和处理。
  3. 性能调优:结合使用多线程和异步编程并不一定能带来性能的提升,需要根据具体的任务特点进行性能调优。例如,要合理分配I/O任务和CPU任务,避免在I/O操作等待时,CPU资源闲置;同时,也要注意线程和协程的数量,避免过多的上下文切换开销。可以通过性能分析工具(如cProfile)来分析程序的性能瓶颈,从而进行针对性的优化。