ITPub博客

首页 > 数据库 > 数据库开发技术 > 深入FastAPI异步编程-之并发编程基础

深入FastAPI异步编程-之并发编程基础

原创 数据库开发技术 作者:程序员小钟同学 时间:2024-01-01 09:48:55 0 删除 编辑

前言

众所众知,FastAPI特点之一是支持异步编程。为什么我们需要使用异步编程,原因就是我们完全可以使用异步编程可以提高 Web 应用程序的性能和可扩展性,因为它允许在等待 I/O 操作(比如数据库查询、网络请求等)时不阻塞应用程序的其他部分,从而使得应用程序能够同时处理多个请求。(也可以理解我们在一个单线程里面也可以进行并发处理多请求。)

在和部分公众号关注朋友中,感觉部分的朋友对相关异步的理解还是不是很深刻,以至于,甚至我们在异步接口中使用同步的函数导致并发局限或引发各种问题。基于此,接下来的几天我个人也分享一点关于我个人学习asyncio的使用以及在FastAPI相关的异步处理一些知识点。

但是我们要深刻的认知和学习异步编程的前提基础包括以下几点:

首先我们需要熟悉 asyncio 库,因为我们的FastAPI 是基于 asyncio 库实现的,因此在学习 FastAPI 异步编程之前,你需要熟悉 asyncio 库的基本概念和使用方法,比如协程、事件循环、任务,未来对象等相关知识点。

另外我们还需要理解异步编程的概念和原理:异步编程是一种编程模型,它与传统的同步编程模型有所不同。在异步编程中,应用程序可以在等待 I/O 操作时执行其他任务,从而提高应用程序的性能和响应能力。因此,在学习 FastAPI 异步编程之前,你需要理解异步编程的概念和原理。

此外,我们还需要掌握 Python 的 async/await 语法:Python 引入了 async/await 语法来简化协程的定义和使用。在学习 FastAPI 异步编程之前,你需要掌握 async/await 语法的基本使用,以便于定义和调用或应用我们的协程。

第一小节 并发与并行的概念

在了解相关的异步编程之前,我们首选需要了解一下关于并发与并行的一些概念。

并发

比较专业的描述说明的话就是:

  • Python中的并发编程是指多个独立执行流同时存在,并且可以同时执行,而不是按照顺序逐个执行。通过并发编程,可以充分利用计算机的多核处理器,提高程序的运行效率和响应速度。

  • 再深入解释一层,并发是指在同一时刻有多个任务在运行,但这些任务可能并不是同时执行的。在并发编程中,任务可以交替执行,即一个任务在一段时间内运行,然后暂停,另一个任务开始运行。这种交替执行的方式使得任务看起来像是同时运行的,但实际上是由操作系统或编程语言的事件循环来管理和调度的。

通俗点描述的话就是;

  • 假设你正在排队买咖啡,在咖啡店中,如果我们的咖啡机只有一个(单核处理器)的情况下,而我们的咖啡店员工(CPU)一次只能处理一个订单,所以你只能耐心等待。但是,如果有多个咖啡机(多核处理器),那么员工可以同时在几个咖啡机上工作,这样你和其他顾客就可以同时得到咖啡,这就是并发。

再通俗的点说的话就是:

  • 并发编程就是让多个任务同时进行,而不是一个接一个地完成。就好比一个人同时做几件事情一样,比如一边听音乐一边看书,或者一边刷牙一边洗脸。是不是多愉快的玩耍的呢?

归结起来,我们结合我们当时场景而言,在Python中并发编程主要应用在需要处理大量并发请求的网络服务、数据分析、科学计算等领域。通过并发编程,我们可以更有效地利用多核CPU的计算能力,提高程序的运行效率。

并行

并行是指多个任务在同一时刻真正地同时执行。当有多个处理器核心或处理器线程时,可以同时执行多个任务,从而实现并行计算。并行计算通常用于执行需要大量计算资源的密集型任务,如科学计算、机器学习和大数据处理等。

并发和并行的区别是什么?

我们可以理解为:

  • 并发:就像是你在看电视的同时,还在用手机聊天,或者在煮咖啡的时候,还在烤面包。你人在做一件事情的时候,还能同时做另一件事情,这两件事情是交替进行的,不是真的同时进行,但给人的感觉就像是同时进行一样。在计算机领域,并发就是通过快速切换,让多个任务在短时间内依次执行,提高效率。【注意这里我们使用的依次交替执行,也就是由始至终只有我们自己一个人】

  • 并行:就像是把一项大任务分成几个小任务,然后把这些小任务同时分配给多个人去完成。每个人都在同时做自己的那份任务,而不是一个人做完了再轮到下一个人。在计算机领域,并行就是利用多个处理器(比如CPU的核心)同时处理多个任务,这样可以大大提高计算速度。【注意这里我们使用的同时处理多个任务,也就是开始有了分身,可以多个人去处理不同的事情】

总而言之,并发和并行都是为了提高效率,但并发是任务交替进行,而并行是多个任务同时进行。在实际应用中,计算机系统会根据任务的性质和资源情况,选择合适的并发或并行方式来提高性能。

理解的并发和并行的区别后后,我们在回到python中,对应python中的并发我们可以通过以下的几种方式实现:

  • 多线程:这是最接近前面我们介绍并发相关概念时候举的示例,我们咖啡店例子的方式。你可以创建多个线程(就像多个咖啡机),每个线程可以独立地执行任务。但是,在Python中,由于全局解释锁(GIL)的存在,即使是多线程,也一次只能有一个线程执行Python字节码。这就好比咖啡店的员工虽然多,但是咖啡机的数量有限,所以还是需要排队等待。

  • 多进程:为了绕过多线程的GIL限制,我们可以使用多进程。每个进程都有自己的内存空间和GIL,这样就可以在不同的进程中同时执行Python字节码。这就像我们多开了几家咖啡店,每个咖啡店都有自己的员工和咖啡机,因此可以同时处理多个订单。

  • 协程:这是Python 3.5引入的一种并发编程方式,也是接下来我们会持续要学习和讲解的内容点。协程允许你在单个线程中实现并发。这听起来有点矛盾,因为我们在前面提到线程本身就有GIL的限制。但是,协程通过一种特殊的协作方式,让线程可以在等待I/O操作时释放GIL,这样其他线程就可以运行。这就像是一个咖啡店的员工在等待咖啡煮好的时候,可以去处理其他顾客的订单。

第二小节 进程、线程、多进程、多线程

进程以及多进程

在 Python 中,进程是指一个正在执行中的程序。它被操作系统分配到计算机的处理器上执行,并拥有自己独立的内存空间。需要注意的是,由于我们的进程间的内存空间是独立的,因此进程间的数据共享相对较为复杂。为了实现进程间的数据交换,我们可以使用进程间通信(IPC)机制,如管道(Pipe)、消息队列(Message Queue)、共享内存(Shared Memory)等各种方式来进行处理。

可能上面的说法理解起来有点太理论话,下面我们可以这么理解:

  • 在Python中,进程就像是计算机中的“工人”。每个工人(进程)都有自己的工作台(内存空间)和工具(资源),他们可以独立地完成分配给他们的任务。你可以想象一下,一个工厂(操作系统)里有多个工人(进程),每个工人都在自己的工作台上忙碌着,有的可能在搬运货物(运行程序),有的在切割零件(运行程序),有的可能在进行产品组装组装(执行任务)。

示例如下:

import multiprocessing
import time

# 定义一个函数,这个函数将是子进程执行的任务
def workerprocess1():
    # 打印子进程的进程ID正在工作
    print(f"Worker {multiprocessing.current_process().pid} is working")
    # 模拟工作耗时
    time.sleep(1)
    # 打印子进程的进程ID已完成工作
    print(f"Worker {multiprocessing.current_process().pid} is done")

def workerprocess2():
    # 打印子进程的进程ID正在工作
    print(f"Worker {multiprocessing.current_process().pid} is working")
    # 模拟工作耗时
    time.sleep(1)
    # 打印子进程的进程ID已完成工作
    print(f"Worker {multiprocessing.current_process().pid} is done")

# 主进程执行的代码
if __name__ == "__main__":
    # 打印主进程已开启
    print("主进程开启了")
    # 创建两个子进程
    process1 = multiprocessing.Process(target=workerprocess1)
    process2 = multiprocessing.Process(target=workerprocess2)
    # 启动两个子进程
    process1.start()
    process2.start()
    # 等待两个子进程完成任务
    process1.join()
    process2.join()
    # 主进程可以在这里继续执行其他任务
    print("子进程的都执行完成.")
    print(f"主进程的PID: {multiprocessing.current_process().pid}")

在上面的代码中我们使用了Python的multiprocessing模块,通过创建两个子进程来执行workerprocess1和workerprocess2函数。 这两个函数中的代码是子进程需要执行的任务。

  • 在workerprocess1和workerprocess2函数中,首先通过print语句打印出当前子进程的进程ID正在工作,然后使用time.sleep函数模拟子进程的工作耗时为1秒,最后再次使用print语句打印出当前子进程的进程ID已完成工作。

  • 对于我们的主进程中的代码则首先打印出"主进程开启了",然后使用multiprocessing.Process类创建两个子进程对象process1和process2,并分别指定要执行的任务为workerprocess1和workerprocess2。

  • 接着使用start方法启动两个子进程,它们会同时执行各自的任务。最后使用join方法等待两个子进程执行完毕,然后打印出"子进程的都执行完成.",并使用print语句打印出主进程的PID

在Python的multiprocessing模块中,每个进程都有唯一的进程ID(PID),这是一个整数,用于标识系统中的每个进程。主进程(即启动其他子进程的进程)和子进程都有各自的PID,我们可以通过调用multiprocessing.current_process().pid来获取得到当前进程的PID。

线程以及多线程

在Python中,线程是一种执行单元,它是多线程程序中的一个独立序列,可以执行程序中的代码。线程是进程的一部分,一个进程可以有多个线程,它们共享进程的资源,如内存和打开的文件。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器、一组寄存器和栈),但它可以与同属一个进程的其它线程共享进程所拥有的全部资源。

简单的理解我们可以理解为,我们的老板(主进程)雇佣多个小秘书,每个秘书负责不同的事情,比如A秘书整理文件、B秘书打印资料,在Python中,线程就像是这些小秘助手,它们可以同时运行,帮你完成不同的任务。但是这些小秘书每个线程都有自己的小本子和记事工具(比如程序计数器、寄存器和栈),但是它们共同为一个老板办事,共享同一个老板(进程)里的资源。

import threading
import time

# A秘书任务
def amishu_taks():
    for i in range(1, 6):
        print(i)
        time.sleep(1)  # 模拟助手在打印时需要等待一会儿

# B秘书任务
def bmishu_tasks():
    for i in range(1, 6):
        print(i)
        time.sleep(1)  # 模拟助手在打印时需要等待一会儿

# 创建两个助手(线程),告诉它们分别去执行各自任务
thread1 = threading.Thread(target=amishu_taks)
thread2 = threading.Thread(target=bmishu_tasks)

# 让助手们开始工作
thread1.start()
thread2.start()

# 等待助手们完成任务
thread1.join()
thread2.join()


运行上面的程序,我们会从控制台看到,同时打印出数字1到5,这说明两个助手(线程)都在同时工作,打印数字。每个助手完成自己的任务后,主线程(老板)会等待它们都完成,然后整个程序也会跟着结束。

需要注意,在Python中,由于一些特殊的锁(GIL),有时候即使有多个助手(线程),它们也不能完全同时做事情,因为锁会限制它们同时访问某些资源。但是,对于一些不是特别复杂的任务,线程仍然是一个很好的帮手。

第三小节 GIL全局解释锁

在前面我们一直提到关于GIL,那GIL是什么呢?在Python中,GIL(Global Interpreter Lock)是一个特殊的锁,GLI代表着"全局解释器锁"(Global Interpreter Lock),它是一种用于控制多线程之间对共享数据的访问,它能确保在任何时刻,只有一个线程可以执行Python代码,这个锁我们可以理解为它是Python解释器的一个保护机制,防止多个线程同时操作解释器内部的数据,可能会导致数据混乱或者不一致。

  • 只有一个线程可以执行python代码意思就是:

我们可以理解为 在同一时间只允许一个线程执行Python字节码,相对于多线程的情况下,即使有多个线程同时运行,但每个线程都无法真正并行执行Python代码。而只有等到当前线程执行完毕或主动释放GIL后,其他线程才能继续执行。

  • 为什么会有GIL存在?(不过现在再最新的python3.13.X版本中已经宣告即将突破这个限制了)

因为Python在设计之初,我们的python创建者并没有考虑到多线程并发执行的情况。随着多核处理器的发展,人们希望Python能够更好地利用多核处理器的性能。但是,由于Python的一些内部实现,Python的内存管理机制并不是线程安全的,如内存管理和垃圾回收,它们并不是为了多线程而设计的,如果多个线程同时访问和修改共享数据,可能会导致数据不一致的问题。这就需要一种机制来保证在多线程环境下,Python解释器内部的数据不会因为并发访问而产生冲突。GIL就是这种机制,它通过限制多线程的并发执行,保证了保证Python解释器的线程安全性,也就是保证了Python解释器内部数据的一致性。

GIL的存在是为了保证Python解释器的线程安全性。因为Python的内存管理机制并不是线程安全的,如果多个线程同时访问和修改共享数据,可能会导致数据不一致或者崩溃。所以GIL通过限制同时只有一个线程执行Python字节码的方式,有效地避免了这些问题。

  • GIL带来的缺陷是什么?

由于我们的GIL在同一时间只有一个线程在执行Python字节码,所以在多核处理器上,Python无法充分利用多核的计算能力(不像java的多线程在多核的情况下可以分配到CPU执行)。尤其是在涉及CPU密集型任务时,GIL可能成为性能瓶颈。当一个线程获得GIL后,其他线程必须等待,这意味着即便有多个线程存在,它们也无法同时在不同的核心上运行。结果就是,在执行CPU密集型任务时,Python的多线程并不能提供性能上的提升,反而可能因为线程切换的开销而降低效率。

需注意:值得注意的是,GIL只存在于CPython解释器中,而不是所有的Python解释器都有GIL。其他解释器如Jython、IronPython等都没有GIL,可以实现真正的多线程并行执行。

  • 举例说明

下面我们举一个简单的例子来解释GIL如何影响Python在多核处理器上的性能。首先我们假设这个程序会在多核处理器上运行,我们有四个核心可供使用。具体代码如下:

import threading

def worker(num):
    print(f"Worker {num} started")
    # 模拟一些计算工作
    result = 0
    for i in range(1000000):
        result += i
    print(f"Worker {num} finished")

# 创建四个线程
threads = []
for i in range(4):
    thread = threading.Thread(target=worker, args=(i,))
    threads.append(thread)
    thread.start()

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

运行上面的代码后,输出的结果如下:

Worker 0 started
Worker 1 started
Worker 2 started
Worker 3 started
Worker 1 finished
Worker 0 finished
Worker 2 finished
Worker 3 finished

从输出的结果我们会看到每个线程依次开始和结束,但不会有四个线程同时运行。所以尽管我们有四个线程,但它们并不是真正的并行执行,因为GIL确保了在任一时刻只有一个线程在执行Python字节码。因此,我们无法充分利用多核处理器的计算能力来加速这个计算任务。

在多核处理器上,由于GIL的限制,Python的多线程无法有效地利用多个核心。在这种情况下,可以考虑使用多进程(如multiprocessing模块)来实现真正的并行计算。下面我们可以切换为多进程的方式来实现,如下的代码所示:

from multiprocessing import Process
def worker(num):
    print(f"Worker {num} started")
    # 模拟一些计算工作
    result = 0
    for i in range(1000000):
        result += i
    print(f"Worker {num} finished")
# 创建四个进程
if __name__ == '__main__':

    processes = []
    for i in range(4):
        process = Process(target=worker, args=(i,))
        processes.append(process)
        process.start()
    # 等待所有进程完成
    for process in processes:
        process.join()

此时运行后,输出的结果如下:

Worker 0 started
Worker 1 started
Worker 3 started
Worker 2 started
Worker 0 finished
Worker 3 finished
Worker 1 finished
Worker 2 finished

观察输出的结果,我们会发现输出的结果是几乎是瞬间的,同时开始的,也同时结束的。这个可以说明了这些任务的执行它们在多核处理器上并行执行,而不是像线程那样顺序执行。这样,我们就可以更有效地利用多核处理器的计算资源来加速计算任务。

  • GIL和多线程

GIL的存在是不是就是说我们使用多线程不合适了呢?这个需要看情况而定,也就是我们所谓的得看是IO型认为还是CPU型的任务。

  • CPU密集型任务:对于需要大量CPU计算的任务,由于GIL的存在,Python的多线程无法实现真正的并行计算。在这种情况下,使用多线程可能不会带来性能上的提升,甚至可能因为线程切换的开销而降低性能。

  • I/O密集型任务:对于I/O密集型任务,如网络请求、文件读写等,由于线程在等待I/O操作完成时会释放GIL,因此在这种情况下使用多线程可以提高程序的并发性和响应性,从而提高性能。

所以我们的多线程使用于什么场景,这个就得先了解一下我们的GIL的释放,而锁的释放通常发生在以下几个情况:

  • I/O操作:当一个线程执行I/O操作(如文件读写、网络通信等)并等待I/O完成时,它会释放GIL。这样,其他线程可以在等待I/O的线程释放GIL后获取GIL并开始执行。
  • 耗时操作:如果一个线程开始执行一个耗时较长的操作(如CPU密集型任务),它会在操作开始时释放GIL,以便其他线程可以执行。一旦耗时操作完成,线程会重新获取GIL并继续执行。
  • 线程同步机制:使用线程同步机制(如锁、事件、条件变量等)时,线程在等待这些同步机制释放时也会释放GIL。
  • Python解释器控制:在某些情况下,Python解释器可能会主动释放GIL,例如在执行某些内置函数或方法时。
  • 抢占式多任务:在Python 2中,GIL可以通过PyPreheel函数被抢占式地释放,允许其他线程在当前线程释放GIL后立即开始执行。但这种方法在Python 3中不再推荐使用。

需注意,GIL的释放是由Python解释器控制的,不是由程序员直接操作的。程序员可以通过编写代码来影响GIL的释放,例如通过I/O操作、线程同步机制或调用特定的Python内置函数,但不能直接控制GIL的释放。

文笔有限,如有笔误或错误!欢迎批评指正!感谢各位大佬!有什么问题也可以随时交流!

结尾

END

简书:https://www.jianshu.com/u/d6960089b087

掘金:https://juejin.cn/user/2963939079225608

公众号:微信搜【程序员小钟同学】

新开QQ群号,欢迎随时加群交流,相互学习。QQ群号:247491107

小钟同学 | 文 【欢迎一起学习交流】| QQ:308711822


原文作者:程序员小钟同学;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2021-02-20

  • 博文量
    224
  • 访问量
    111698