/同步、异步、阻塞、非阻塞

Created Sat, 13 Apr 2024 23:39:22 +0800 Modified Tue, 07 May 2024 01:58:26 +0000
2069 Words 9 min

概念

同步和异步、阻塞和非阻塞这两组概念经常出现,并且人们往往会有如下认知:

  • 同步就是程序发出同步调用之后就需要等待调用返回一个结果,然后才能继续指令的执行流。

  • 异步就是程序发出异步调用之后能直接得到返回,程序可以继续执行,至于调用发起者想要得到的结果会在未来的某个时刻获取。

  • 阻塞就是在调用结果返回之前,当前线程会被挂起。

  • 非阻塞就是再不能立刻得到结果之前,当前线程并不会被挂起。

那么这样来看的话,同步调用就是阻塞调用,异步调用就是非阻塞调用,这个认知是有些狭隘的。

同步和异步

同步和异步主要 focus 的是调用者和被调用者双方消息通信的机制。

同步是调用者等待被调用者返回结果,异步则是调用被直接返回,调用者不会等待被调用者。

以例子来说明的话就是:假如你打开了崩铁想玩,但是却发现需要下载更新客户端:

  • 如果采用同步的方式就是你一直等着下载安装完成,期间什么都不做。

  • 不过我相信正常人都不会在这个过程中干等着什么都不做,而是会在点击下载按钮之后玩会儿手机或者干点别的事,这就是异步的方式。

在这个例子中我们可以发现:

  • 如果采用同步的方式,我们一定能在更新完成之后的第一时间立刻玩到游戏,但是在苦苦等待的过程中我们的时间被浪费掉了。

  • 如果采用异步的方式,我们在等游戏更新完成的过程中做了其他事情,时间没有被浪费掉,但是我们需要一种机制来知道什么时候游戏就更新好了。假如在下载过程中我们去做了别的事情,那么就可能不会第一时间知道它什么时候更新完成。

如果把我们自己比作 CPU 的话,并且假设目前 OS 上面只有这一个任务,同步的方式会浪费 CPU 时间,而采用异步的方式可以让我们多做一些别的事情,不过异步需要一些消息通知的方式来告诉我们等待的任务什么时候会有结果。假如崩铁下载器在下载完成之后没法通知我们,那么我们可能需要隔一段时间检查一下有没有更新完成。

这么看来,其实同步就是 OS/函数调用 默认支持的通信方式(无非就是等呗),而异步虽然可以解决同步会浪费时间的问题,但是需要引入 消息通知(下载器窗口变成启动游戏的窗口,并且置于最前)/注册回调函数(假如可以派个人替我玩的话)/轮询(隔几分钟看看有没有更新完)这些机制才能保证完成任务。

从线程/协程的角度来看同步和异步的话,其实同步就是完完全全的单线程模式,而异步可以利用协程的特性在单线程中完成异步任务,从而避免大量使用回调函数带来的“回调地狱”。

以实际的例子来说明,在使用 neovim 写代码的时候会使用代码格式化的功能,默认的代码格式化的同步完成的,也就是说我们需要等格式化完成才能执行别的任务(从阻塞的角度看就是,neovim 被格式化的过程阻塞了,这种方式就是同步且阻塞的方式)。在文件很小的时候,因为格式化很快所以以同步的方式进行格式化并不会有太多的影响。但是如果需要进行大文件的格式化,同步的方式会阻塞很久,严重影响体验。从更高的角度来看,格式化器影响的主要是代码的位置(可能也会影响代码的内容例如 goimports ),那么理论上我们不进行与代码内容和代码位置相关的写入操作就不会造成写冲突。但是这种同步的方式就是一种一刀切,使我们只能等格式化完成,这其实不太合理。

为什么说这个例子可以用协程的方式实现异步呢?其实原理就是局部性 + 协程特性。因为我们在写代码的时候通常只是会编辑一处的内容,如果我们下达了对整个大文件的格式化操作,那么理论上是可以按照不同的小部分(比如一个函数)来完成格式化过程的,而在完成格式化一个函数的过程中,CPU 的执行权可以交给格式化器,而在用户需要进行一些别的操作的时候,格式化协程可以挂起(yield)并将 CPU 让给用户操作的协程,而当用户的操作完成之后,格式化协程可以恢复(resume)并获取 CPU 继续执行。这样来看,通过对任务的分割和对协程的交替切换,就实现了异步的机制。

阻塞和非阻塞

阻塞和非阻塞主要 focus 的是调用者在等待调用结果时候的状态。

还是以上面的例子来说:

  • 阻塞描述的是我们在等待游戏更新完毕的过程中,处于什么都干不了的状态(我只想玩崩铁,我啥都不想干!),

  • 非阻塞描述的是在游戏更新的时候,我们可以干点别的,比如看一集《葬送的芙莉莲》(这个时间正好能多看一集番,美滋滋~)。

对于实际的编程场景而言,阻塞和非阻塞这组概念常常在 Socket 编程中出现,我们可以利用 fcntl 把 socket 置为阻塞或者非阻塞的状态(默认是非阻塞)

对于 TCP 而言,其对应的发送和接收的 API 是 send/recv,而 send/recv 其实并不是真的直接向网络上发数据/直接从网络上接收数据,而是将数据写入到内核发送缓冲区/从内核接收缓冲区读取数据。

如果发送端一直往发送缓冲区写数据而接收端不读数据的话(其实就是流量的滑动窗口不滑动了),当缓冲区满了之后:

  • 如果 socket 是阻塞模式,继续调用 send 会将程序阻塞在 send 处,不会执行之后的逻辑。

  • 如果 socket 是非阻塞模式,继续调用 send 会直接返回错误,然后执行之后的逻辑(通常使用非阻塞模式我们会获取 send 调用的返回值并在循环中判断)。

总结

其实总的来看,在实际的编码过程中我们没必要严格区分这两种概念,因为它们之间的区别并不是左与右,正与负这种关系。概念还是需要与实际的例子相结合才有相辅相成的意义。