理解同步异步与阻塞非阻塞——傻傻分不清楚的终极指南

2024-08-05 13:54:25 浏览数 (2)

同步异步与阻塞非阻塞这两组概念在 IO 场景下非常常见,由于他们在表现出来的效果上很相似,很容易造成混淆和困扰,要想理清楚这两组概念首先需要认识到这两组概念强调的是不同维度的事。

同步异步强调的是两个操作之间的顺序关系,两个操作之间是有序的还是无序的;

阻塞与非阻塞强调的是一个调用发起后调用发起方的行为,是被动等待还是主动获得执行权

下面以 Python 代码为例介绍这几个概念。

同步关系与异步关系

因为同步异步强调的是两个操作之间的顺序关系,所以加上关系俩字更好理解和区分。

同步 "Synchronous" 这个词源自希腊语 "syn"(意为"一起")和 "chronos"(意为"时间"),它的字面意思是"在同一时间发生"。在通信和计算机领域中,“同步”则有两层含义,一个是"一起发生",另一个是"按顺序进行",这两层含义缺一不可,它意味着多个操作按照预定的顺序和时间协调进行,从而保持整体的一致性和协调性。

这里可以联想一下并发控制中为什么存在“同步互斥”这样的概念?目的就是为了协调多进程访问临界区时,必须等临界区中的 A 进程退出临界区后,B 进程才可以进入临界区执行,本质上是将并行(异步)关系变成了串行(同步)关系。

再回想一下 SQL 隔离级别中最高级串行化 Serializable 是不是更能理解了?同样是将并行(异步)关系变成串行(同步)关系。

同步关系 (Synchronous)

同步指的是某个操作 A 必须等待前一个操作 B 完成之后才能开始,也就是说 A 在 B 完成之前不会启动。

也可以描述为 A sync before B,意味着操作 A 在操作 B 之后按顺序执行,并且 A 必须等待 B 完成后才开始。

说白了同步意味着 A 和 B 之间的执行有先后顺序关系,中国有句古话:先穿袜子再穿鞋,先当孙子再当爷,讲述的就是这个道理。

同步例子,其中 task_Atask_B 是同步关系,只有 task_A 执行完了task_B 才能执行。

代码语言:javascript复制
import time

def task_A():
    print("Task A started")
    # 模拟耗时操作
    time.sleep(2)
    print("Task A finished")

def task_B():
    print("Task B started")
    # 模拟耗时操作
    time.sleep(1)
    print("Task B finished")

# 同步执行:B 必须等待 A 完成
task_A()
task_B()

输出

代码语言:javascript复制
Task A started
Task A finished
Task B started
Task B finished

异步关系 (Asynchronous)

在异步操作中,操作 A 不需要等待前一个操作 B 完成之后才能开始,A 和 B 可以同时进行,或者 A 可以在等待 B 的过程中执行其他操作。

可以描述为 A async with B 意味着操作 A 和操作 B 可以同时执行或 A 不需要等待 B 完成。

说白了 A 和 B 的执行没半毛钱关系,你在穿鞋的同时也可以喘气儿,先喘再穿还是先穿再喘甚至边穿边喘都可以,怎么喜欢怎么来,互不影响。

异步例子,task_Atask_B 同时执行,都不需要等待对方,各自爱怎么跑怎么跑。

代码语言:javascript复制
import asyncio

async def task_A():
    print("Task A started")
    # 模拟耗时操作
    await asyncio.sleep(2)
    print("Task A finished")

async def task_B():
    print("Task B started")
    # 模拟耗时操作
    await asyncio.sleep(1)
    print("Task B finished")

# 异步执行:A 和 B 可以同时进行
async def main():
    await asyncio.gather(task_A(), task_B())

asyncio.run(main())

输出

代码语言:javascript复制
Task A started
Task B started
Task B finished
Task A finished

阻塞调用与非阻塞调用

阻塞和非阻塞重点强调的是调用方在发出调用后的行为,为了更好的理解这一对儿概念,可以在阻塞和非阻塞后面加上“调用”俩字,变成阻塞调用和非阻塞调用。

阻塞调用 (Blocking)

阻塞调用发出后,调用方会挂起等待,当被调用方执行完成并返回结果后,调用方才会被唤醒并接到结果继续执行之后的操作。

说白了阻塞调用就是发出调用后傻等着,整个进程都等在调用发出这一行。

代码示例,下面代码中 blocking_operation 内部有一个耗时操作,main 函数中进行阻塞调用,blocking_operation 不返回就一直在这等。

代码语言:javascript复制
import time

def blocking_operation():
    print("Starting blocking operation")
    time.sleep(2)  # 模拟耗时操作
    print("Blocking operation finished")

def main():
    print("Before blocking call")
    blocking_operation()  # 阻塞调用
    print("After blocking call")

main()

输出

代码语言:javascript复制
Before blocking call
Starting blocking operation
Blocking operation finished
After blocking call

非阻塞调用 (Non-blocking)

非阻塞调用发出后,调用方不会挂起等待,而是立即返回,之后可以选择继续别的操作。被调用方在后台(可能以各种形式实现)处理原本的业务逻辑,处理完成后可以通过回调、信号等机制通知调用方。

说白了非阻塞调用就是发出调用后马上返回,无论能不能得到想要结果都义无反顾的返回,啪的一下很快啊。至于结果没拿到怎么办?可以循环重试啊。

代码示例,下面代码中 non_blocking_operation 中有一个耗时操作,但调用时以非阻塞方式调用,立刻返回并继续执行 main 函数后面内容而不是一直等待。

代码语言:javascript复制
import asyncio

async def non_blocking_operation():
    print("Starting non-blocking operation")
    await asyncio.sleep(2)  # 模拟耗时操作
    print("Non-blocking operation finished")

async def main():
    print("Before non-blocking call")
    task = asyncio.create_task(non_blocking_operation())  # 非阻塞调用
    print("After non-blocking call")
    await task  # 等待任务完成

asyncio.run(main())

输出

代码语言:javascript复制
Before non-blocking call
After non-blocking call
Starting non-blocking operation
Non-blocking operation finished

两两结合

现在说说这两组概念的两两结合,设想这样一个场景,在一个主流程 main 中希望调用 read 发起 IO 读取数据,根据 mainread 的顺序关系以及 main 发出调用后的状态可分为如下几种情况:

同步阻塞

同步意味着 main 只有在 read 完成后才能继续执行,同步意味着有序;

阻塞意味着只要 read 不返回则 main 就必须挂起等待。下面是一段示例:

代码语言:javascript复制
import time

def read():
    print("read start")
    time.sleep(2)
    print("read finished")

    return "data"

def main():
    print("Before read")
    data = read()  # 同步阻塞
    print("After read:", data)

main()

输出

代码语言:javascript复制
Before read
read start
read finish
After read: data

同步非阻塞

首先说结论这种模式很少有实际应用。

同步意味着 main 只有在 read 完成后才能继续执行,同步意味着有序;

非阻塞意味着 read 调用会马上返回所以 main 可以立刻获得 CPU 时间片得以继续执行,但由于 mainread 之间是同步关系,main 必须等待 read 真正完成后才能继续执行,那么 main 只能主动放弃执行进而等待类似回调机制的通知。

因为 main 已经获得了执行权但却又不真正执行,等同于浪费了 CPU 的调度和时间片,所以这种情况在实际应用中很少就不写例子了(实际上我没想到有什么典型的例子可以写)。

异步阻塞

首先还是说结论这种模式的应用也非常少。

异步意味着 mainread 的执行互不影响,相互之间并不存在谁要等谁的情况,可以各自愉快滴运行,异步意味着无序。

阻塞意味着 main 调用 read 后必须等待 read 的结果返回,实际上这也浪费了 mainread 之间的异步关系,本可以并行执行的,现在只能挂起等待,所以实际应用并不多,也没有特别好的例子可写的。

异步非阻塞

异步意味着 mainread 的执行互不影响,相互之间并不存在谁要等谁的情况,可以各自愉快滴运行,异步意味着无序。

非阻塞意味着 read 调用后可以马上返回,同时由于二者是异步关系,所以可以实现 mainread 各自都可以继续向下执行,并发效率是最高的。

代码语言:javascript复制
import asyncio

async def read():
    print("read start")
    await asyncio.sleep(2)
    print("read finished")

    return "data"

async def main():
    print("Before read")
    task = asyncio.create_task(read())  # 异步非阻塞
    print("After read")
    data = await task
    print("Read data:", data)

asyncio.run(main())

输出

代码语言:javascript复制
Before read
After read
read start
read finished
Read data: data

异步非阻塞的应用价值

曾几何时江湖上流传着一个名为 c10k 的问题,说的是服务器如何应对 10000 个网络连接的场景。这其中的主要矛盾是人民群众日益增长的高质量互联网应用的需要与落后的服务器并发能力之间的矛盾,因为 fork 多进程模型在处理大量连接时资源消耗是非常严重的,通过增加服务器集群数量已经不能解决根本问题,迫切需要一种新的解决方案的出现,异步非阻塞就是在这样的背景下提出来的。

最早接触异步非阻塞是 Python 的 tornado 框架,记得当时 tornado 的官网上还有 c10k 问题的介绍,主打的就是一个支持高并发高性能的网络框架,可以完美应对 c10k,tornado 一度成为了 Python Web 领域高性能的代名词。

不过经过这么多年的发展,结合多路复用 IO 以及各种语言分别加入了异步编程特性,c10k 已经不再被视为一个问题,反而成为了高性能高并发技术的里程碑。

下面就以 Python 为例写一段代码,体现异步非阻塞的价值所在。

Python 在 3.5 版本之后引入了 async await 等一系列原生支持的协程语法,之前想要实现协程一般使用 yield 结合一些装饰器,写起来心智负担比较重,有了 async await 通过协程实现异步编程就简单多了。

这段代码使用 aiohttp 库实现了一个 http server,其中 handle 方法通过 sleep 模式执行一段 IO 操作, time.sleep(5) 表示以同步方式执行,await asyncio.sleep(5) 表示以异步方式执行。

代码语言:javascript复制
import aiohttp
import asyncio
import time

async def handle(request):
    # 用 sleep 模拟一个耗时的IO操作,下面分为同步和异步两种方式

    # 同步 即事件循环与io是同步关系
    # 事件循环需要等待io完成后才能获得执行权继续执行
    # 在io没完成之前,事件循环是无法处理其他客户端的请求的
    time.sleep(5)

    # 异步 即事件循环与io是异步关系
    # 事件循环和io操作是并行运行的
    # 在io没完成之前,事件循环可以获得执行权去处理其他客户端的请求
    # await asyncio.sleep(5)

    return aiohttp.web.Response(text="Hello World!")

app = aiohttp.web.Application()
app.router.add_get('/', handle)

if __name__ == '__main__':
    aiohttp.web.run_app(app, host='0.0.0.0', port=8080)

启动服务

代码语言:javascript复制
======== Running on <http://0.0.0.0:8080> ========
(Press CTRL C to quit)

再编写一个并发请求的脚本,可以同时发起 http 请求,观察请求执行时间可以看出,同步和异步两种方式的区别,其中 time 命令可以统计 curl 执行时间,输出的 real 表示耗时秒数。

代码语言:javascript复制
time curl 127.0.0.1:8080 &
time curl 127.0.0.1:8080 &

脚本启动后可以观察使用同步和异步两种方式的耗时的不同

代码语言:javascript复制
# 同步
> ./time.sh
Hello World!
real	0m5.037s
user	0m0.008s
sys	  0m0.008s
Hello World!
real	0m10.049s
user	0m0.008s
sys	  0m0.010s

# 异步
> ./time.sh
Hello World!Hello World!
real	0m5.052s
real	0m5.049s
user	0m0.007s
user	0m0.006s
sys	  0m0.010s
sys   0m0.012s

能看到同步方式下第一次请求耗时 5s 而第二次请求耗时 10s,也就相当于两个并发请求被串行化了。在异步方式下两次请求分别耗时 5s,互不影响。 异步非阻塞结合协程在高并发场景下,可以花费较少代价便能够支持大量网络连接,这是非常有价值的。

总结

想要彻底搞清楚同步和异步、阻塞和非阻塞,就要明确他们分别是从两个维度出发强调的不同概念。前者强调的是两个操作之间的顺序关系,后者强调的是调用方发出调用后的行为,搞清楚这两个维度才能够清晰的理清楚他们之间的关系。

0 人点赞