现代化程序开发笔记(11)——异步编程杂谈

本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将以我的理解从头开始梳理一遍异步编程。

从网络IO开始

作为一个服务器程序,最重要的就是维护网络的IO。我们知道,一个TCP连接对应一个TCP套接字,服务器程序需要做的,就是妥善处理这些套接字中的数据。粗略地说,一个服务器程序做的事如下:

  1. 告诉内核自己监听了哪些套接字端点(socket endpoint)
  2. 内核维护TCP连接,并将接收到的数据传递给服务器程序
  3. 服务器程序处理数据

这就好比一家快餐店,而内核中的一个套接字端点就是厨房,厨房中有许多条流水线,比如说薯条流水线、汉堡流水线和炸鸡流水线,这些流水线就是与该套接字端点连接的套接字。服务器程序的作用就是服务员,他需要在每条流水线制作完成一个食材后就将对应的食材取出,并进行进一步的处理。

怎样实现这样的功能呢?这实际上是经历了长久的演变(以Linux为例)

accpetrecvfrom

最原始的方法就是acceptrecvfrom,这默认是一种阻塞式的IO。用快餐店的例子类比的话,服务员就在厨房门口干等,啥事也不干,看着哪个流水线好了就处理哪边的食材,处理完了继续回来干等着。

也就是说,我们以下的程序

int s = socket(...); // create socket endpoint
// bind and listen
int c = accept(s, ...); // create socket
recvfrom(c, ...); // receive data

做了什么事呢?

  1. 告诉我们的服务员去哪个厨房工作,也就是创建、绑定和监听套接字端口。
  2. 如果此时厨房里没有流水线在工作,那么服务员啥也不干。也就是当前套接字端口没有连接时,该进程被移至等待队列中。在程序中就是调用accept函数被阻塞。
  3. 当厨房中出现了一个流水线,服务员就盯着这个流水线,啥也不干。也就是当accept出现返回值时,调用recvfrom函数,当没有数据返回时进程被阻塞。
  4. 等到流水线处理完毕食材,服务员就开始处理对应的食材了。也就是当recvfrom函数返回之后,进程就可以继续运行了。

这样的设计看上去就让人很难受。首先,accept函数会阻塞进程,但这是合理的,因为我们假定我们的服务器程序只用作处理网络连接,那么没有网络连接的时候自然就不用工作。但是,在accept之后,进程只能处理这一个套接字,并且当没有数据的时候,进程就被彻底阻塞了。

想象一下这样的情景:汉堡流水线最先开始工作,所以服务员就到汉堡流水线前面干等着,盯着看。之后,周围的薯条流水线、炸鸡流水线也开始工作了。但是,做汉堡的师傅比较慢,周围薯条流水线都做好十份了,炸鸡流水线也做好五份了,汉堡流水线还没做好一份汉堡,但这时服务员只能干等着汉堡流水线的师傅,造成极大的资源浪费。

有了多线程技术以后,这种现象稍微有所缓解。我们可以想象成,快餐店多请了几个服务员,比如说一共有8个服务员了。那么可以每个服务员盯着一个流水线,这样就不会产生刚刚的现象了。真的吗?并不。我们知道,服务员的数量是有限的,那么如果此时流水线的数量大于服务员的数量,依然会有流水线得不到照顾。与此同时,流水线的另一端并不是我们掌控的,而是用户发起的。那么,会不会有坏用户,只与我们的服务器建立连接,但不发送数据。那么我们如果有一个服务员被分配到这样一个流水线上,就会造成在很长一段时间里,这个服务员都啥也不干,而别的流水线却极度需要服务员来处理食材。如果坏用户多了,把我们的服务员都占用了,那么我们也就没有服务员能处理正常的流水线了。这就是DoS攻击的一种手段。

非阻塞IO

缓解这种困境的一种手段就是是由非阻塞的IO。服务员不再在流水线前干等,而是走到流水线前,看一眼流水线好了没有。如果流水线好了,生产出了我们要的食物,那么服务员就正常工作;如果流水线还没好,那服务员就不再干等了,可以去干别的事了。对于单个套接字来说,这个工作就是由非阻塞IO完成的。我们用socket创建套接字端点的时候,可以指定为非阻塞套接字,那么我们接下来对非阻塞的套接字使用recvfrom的时候,如果数据还没有准备好,函数可以直接返回,而不是被阻塞。

IO多路复用:selectepoll

除此之外,recvfrom每次只能处理一个套接字,所以人们引入了selectselect相当于我们雇用了一个厨房的总管,服务员每次向厨房总管调用select语句之后,厨房总管看一遍所有的流水线,然后告诉服务员,有没有流水线是已经完工的。如果有流水线已经为做好食材,那么服务员就去挨个看流水线,找到是哪个流水线完成的,然后处理那个流水线的食材就好了。

之后select由于一些设计上的缺陷,又产生了poll函数,但两者实际原理都是差不多的。

但是我们想象一下,如果是一个大型的服务器程序,那么可能会有成千上万个套接字连接,那么厨房总管告诉服务员有已经好了的流水线时,服务员又得从头遍历一遍所有流水线,这样的事件损耗是巨大的。

epoll就是为了改善这种情况而发明的。改善的方法也很简单,既然厨房总管也要看一遍哪个流水线好了,那么厨房总管就拿个纸,记下来好了的流水线,然后交给服务员让他去找就完事了,完全不需要服务员再遍历,这就是epoll的作用。

信号驱动与异步IO

我们发现,随着人们技术的提高,服务器程序的水平也越来越高了。我们快餐店的服务员,从原来只会干等,到现在会看一眼进行判断,或者和厨房总管进行交接了。

但是,厨房里面的水平却没有多少提高。这导致我们的服务员的效率依然很低。比如说,我们终于用上了非阻塞IO,然后服务员负责某一个流水线。他首先看到流水线还没好,就去干别的事了。别的事干完之后,他又来看一遍,发现还没好,然后又去干别的事,然后又来流水线这看一眼。如果他此时并没有别的事,那他还是一直在流水线跟前看一眼,看一眼,看一眼,和之前的阻塞式IO没有区别。反映到程序里,我们的代码依然是

// create nonblocking socket c via fcntl
while (true) {
    if (recvfrom(c, ...) != -1) {
        // receive data
    } else if (errno == EAGAIN) {
        // do something else
    }
}

依然是这样的循环结构。

虽然我们用上了非阻塞IO、IO多路复用,但这样始终让人感觉别扭。我们回想一下,现实中服务员和流水线,似乎也没有这么别扭的事发生啊。

这一切的原因,都是我们的编程思路没有跳脱开来,仍然局限在同步编程的概念里。我们想象一下现实生活中究竟是怎样的:服务员在忙别的事,这条流水线的食材做好了,师傅就按个铃。服务员听到铃声之后,要么立刻停下手头的事去流水线,要么默默记下来,等做完手头的事以后就去流水线。如果没有铃声,那么服务员就始终不用去流水线门口等着了。这就是异步编程的思想。

Linux中,在网络IO中引入了信号驱动型IO和aio来完成厨师的按铃工作。其主要思想就是,将一个函数传递给内核,告诉内核如果好了就调用这个函数。信号驱动型IO是在数据报的传输和内核处理层面的异步,也就是说,信号驱动型IO需要我们传递一个函数给内核,告诉内核如果来数据报了,并且内核已经把数据报处理好了,就调用我们之前传入的信号处理函数。而我们依然要在信号处理函数中使用recvfrom等函数,将内核数据拷贝到用户态来。而aio则是更进一步,告诉内核,如果来数据报了,内核把数据报处理好了,并且也已经拷贝到用户态了,再调用我们传入的函数。

异步编程

从最原始的网络IO开始,我们一步步终于接近了异步编程。异步究竟是什么呢?异步实际上就是一种编程的思维,它在代码上就体现为,我们现在写的东西不会立刻被调用,甚至这个东西也不是被它的执行者直接调用。假如我们是快餐店的老板,那么我们告诉服务员,如果厨师按铃了,那你就去端菜。「服务员去端菜」这件事并不是在他知道这件事之后就立刻去做,而是要等到厨师按铃之后才做;同时,这件事虽然是服务员自己执行的,但是并不是服务员自己调用的,而是厨师通过按铃调用的。这就是异步编程的思想。

回调函数

异步编程最原始的实现就是回调函数。比如说我们用Swift来实现我们的异步快餐店:

func makeHamburger(completionHandler: (_ hamburger: Hamburger) -> Void) {
    let hamburger = // make hamburger
    completionHandler(hamburger: hamburger)
}

func serve(_ hamburger: Hamburger) {
    holdTheHamburgerToCustomers(hamburger)
}

makeHamburger(completionHandler: serve)

makeHamburger是厨师该干的活,而我们需要让厨师接受一个回调函数作为参数。接着,我们实现服务员听到铃声后该干的事,也就是serve函数。我们需要做的,就是将服务员该干的事传递给makeHamburger。在这之后,当汉堡制作完成后,我们需要在函数内部调用回调函数即可。

看上去很不错,这就是我们的异步编程了!我们还需要别的吗?这看上去多简洁明了!

然而,我们还是太天真了。我们来看callbackhell.com官网上给出的一个JavaScript中的例子:

fs.readdir(source, function (err, files) {
  if (err) {
    console.log('Error finding files: ' + err)
  } else {
    files.forEach(function (filename, fileIndex) {
      console.log(filename)
      gm(source + filename).size(function (err, values) {
        if (err) {
          console.log('Error identifying file size: ' + err)
        } else {
          console.log(filename + ' : ' + values)
          aspect = (values.width / values.height)
          widths.forEach(function (width, widthIndex) {
            height = Math.round(width / aspect)
            console.log('resizing ' + filename + 'to ' + height + 'x' + height)
            this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
              if (err) console.log('Error writing file: ' + err)
            })
          }.bind(this))
        }
      })
    })
  }
})

这就是完全按照回调函数的思路写出来的异步编程代码。我们来看最后几行,全都是})}。一层一层的回调函数看上去让人头皮发麻。就好像:

  • 我们告诉养鸡的,去把鸡养大,等鸡养大之后:
    • 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:
      • 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:
        • 去炸鸡,然后告诉服务员让他做准备:
          • 去把炸鸡端给顾客。

这一层一层的传递关系已经够乱的了,接下来,为了让我们的程序更稳健,我们还需要做错误处理。我们得:

  • 让服务员出错的时候,告诉炸鸡的不能端菜了
    • 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了
      • 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了
        • 养鸡的自己出错,或者收到杀鸡的出错的消息,就告诉我们

合起来,我们得这样写:

  • 我们告诉养鸡的,去把鸡养大,等鸡养大之后:
    • 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:
      • 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:
        • 去炸鸡,然后告诉服务员让他做准备:
          • 去把炸鸡端给顾客。
          • 服务员出错的时候,告诉炸鸡的不能端菜了
        • 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了
      • 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了
    • 养鸡的自己出错,或者收到杀鸡的出错的消息,就告诉我们
  • 我们来处理所有人出错的问题

让人头皮发麻,并且没办法维护。

响应式编程与观察者模式

除了回调函数之外,我们还有别的异步编程的方法。试想,使用回调函数的方法,我们为了让服务员正确服务,我们却得告诉厨师去调用服务员的函数。厨师本来就应该做好自己的事,而不是去关心别的人的事。这在程序开发中叫做弱耦合。为了实现弱耦合,我们可以引入观察者模式,它发扬光大就是著名的响应式编程。

观察者模式说的是,我们可以将我们需要关心的东西设置为可观察的对象,然后将某些东西设置为它的观察者。当这个对象发生改变的时候,它的观察者就会做出相应的举动。在我们的快餐店中,我们可以让服务员成为汉堡的观察者。当汉堡做好的时候,服务员就去端汉堡。

C#中的Rx是响应式编程的元老(但我没用过),以及Android框架中的LiveData就是观察者模式的一个很好的实现。

class WaiterViewModel : ViewModel() {
    private var hamburger: MutableLiveData<Hamburger?> = MutableLiveData(null)
    
    fun getHamburger(): LiveData<Hamburger?> {
        return this.hamburger
    }
    
    fun makeHamburger() {
        val hamburger = // make hamburger
        this.hamburger.value = hamburger // or this.hamburger.postValue(hamburger) if in another thread
    }
}

class WaiterFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // get cook the viewModel by delegation
        val cook: WaiterViewModel by viewModels()
        // create Observer
        cook.getHamburger().observe(viewLifeCycleOwner, Observer { hamburger in
            hamburger?.let {
                holdTheHamburgerToCustomer(it)
            }
        })
    }
}

在这里,我们将服务员看作一个界面,厨师看作它的view model,重点就在observe函数,指定了如果汉堡发生了变化,也就是产出了汉堡,那么服务员要去端汉堡。

值得注意的是,这和我们最开始的同步模式,也就是服务员要一直盯着汉堡不同,这里服务员依然可以去做别的事。

与观察者模式类似的,我们还有订阅-发布模式,其本质与观察者模式相同,只不过,我们让厨师做好汉堡之后,发一个公告,说汉堡做好了。然后服务员订阅这个布告栏,如果发布了公告,那么服务员就去端汉堡。

实践订阅-发布模式的,就是Apple在去年推出的Combine框架。利用Combine框架,我们可以这么写:

func makeHamburger() -> Publisher<Hamburger> {
    // make some hamburger
}

func serve(_ hamburger: Hamburger)

makeHamburger()
    .sink { hamburger in
        holdTheHamburgerToCustomer(hamburger)
    }
    .store(in: disposables) // store in disposables in case we lose reference

不管怎么样,我们都是把回调函数从厨师那边解放了,而把回调函数独立于两者之外,由我们来指定。

这种模式还有一种巨大的好处是我们可以主动取消回调。比方说客人点了一份炸鸡,然后服务员让厨师做炸鸡。服务员就观察着这个炸鸡,一旦它做好,就端给客人。但是客人临时有事,又要走了。服务员就取消观察这个炸鸡就ok了。但是在回调函数的方案中,函数是直接传给厨师的,取消起来很麻烦。

协程,Promiseasync/await

观察者模式可以做的远远不止服务员端汉堡这么多。比如说服务员可以一直观察着厨师做汉堡的过程。当厨师烤好肉了之后,服务员大喊一声“厨师肉烤好啦!”,之后厨师又把肉、菜、酱加在一起,服务员大喊一声“厨师加了菜和酱啦!”,厨师最后做完汉堡,服务员才开始把汉堡端给顾客。也就是说,观察可以是一段时间持续的观察,期间任何的变动都可以反馈给观察者让观察者做出相应的举动。

但是,有时候我们的需求不是这样的。我们仅仅需要的是一个结果,某人做了某事,我们就怎么样。比方说,我们的薯条厨师,开始做薯条的时候,要先打个电话给土豆场,让他们送土豆过来。土豆送过来之后,厨师又让学徒切土豆。切好之后,厨师又让助手把油烧开,等油烧开之后,厨师才开始炸薯条。这个过程,依然是一个需要异步调用的过程,而且如果用回调函数的方式写,又会产生之前所说的callback hell。

Promise

那我们就模仿之前观察者模式的思路,看看能不能解决。这里,我们发现,这里面的环节只会变化一次。土豆会从无变成有,从没切好变成切好,从没炸变成炸过。我们模仿之前的LiveData<T>或者Publisher<T>,这里我们记为Promise<T>。它有两种状态:还没好的状态,和好了的状态。根据之前观察者模式的经验,我们似乎可以这样写:

function bringTomato(): Promise<Tomato>;
function sliceTomato(tomato: Tomato): Promise<SlicedTomato>;
function fryTomato(slicedTomato: SlicedTomato): Promise<Chips>;

当我们调用bringTomato之后,会立刻返回一个Promise<Tomato>。当土豆到了以后,这个变量会自动变成OK的状态。然后,就像之前的观察者或者订阅者一样,我们对这个变量进行观察或订阅,并传入一个回调函数:

bringTomato()
    .then(sliceTomato);

Promise<Tomato>从没好的状态变成好的状态的时候,会调用then中的函数,并把好了的值传进去。看上去很OK嘛!

那么,then应该是怎样的工作呢?这很像我们之前在函数式编程中讲的functor和monad,这里,我们应该把then看作monad中的bind。这是因为,我们的sliceTomato依然会返回一个Promise类型的值,如果是functor的fmap的话,在执行完毕后,会产生Promise<Promise<SlicedTomato>>这样奇奇怪怪的类型,所以还是用monad比较顺心一点。也就是说,会自动“折叠”我们的Promise,让它只剩下最后一个。这样的话,我们就可以把它串起来了:

bringTomato()
    .then(sliceTomato)
    .then(fryTomato)
    .then(doSomethingWithChips);

看上去还是挺OK的。

协程与async/await

那我们换一种思路,用最原始的同步方法行不行?行!薯条厨师打电话给土豆场之后,啥也不干,等着土豆送到之后,让学徒切土豆。切好之前,又啥也不干……这样确实可以有效地解决回调地狱的问题,但是这又回到了最原始的同步思路上,在我们实际程序中,一个厨师可能就是一个线程。让厨师啥也不干就干等着,这线程的利用率也就太低了。如果我们单纯写

let tomato = bringTomatoSync();
let slicedTomato = sliceTomatoSync(tomato);
let chips = fryTomatoSync(slicedTomato);

这里sync代表是同步的函数,那么当事情还没完成的时候,线程会被阻塞,不符合我们异步的想法。我们能不能用类似于上面的语句,来写出之前用Promise的功能呢?像这样:

let tomato = await bringTomato();
let slicedTomato = await sliceTomato(tomato);
let chips = await fryTomato(slicedTomato);

它和我们的同步写法究竟有什么区别呢?要解决这个问题,就要首先思考,我们为什么不用同步写法。同步的写法就意味着当函数调用还没结束的时候,我们需要一直处于等待状态。但是,如果可以在bringTomato的时候,我们的炸薯条的师傅去做别的事,那么效率就可以得到提升。也就是说,我们之所以不用同步的写法,是为了让程序在等待的过程中能做别的事。

那么,我们如果要实现这样的功能,需要怎样的策略呢?作为一个厨师,有条理地做这种事,应该是:

  1. 打电话给土豆场,让他们运土豆过来
  2. 做别的事
  3. 别的事做完以后,再来处理运来的土豆

也就是说,我们需要在特定的时候主动停下手头的事,然后去做别的事。也就是说,我们需要有主动停止的能力,或者说,主动让出控制权的能力,这就是协程。在JavaScript中,语言提供了一个叫generator的机制来模拟这种事:

function *foo() {
    yield bar1();
    bar2();
    yield bar3();
    bar4();
    return;
}

当我们调用foo的时候,会首先执行bar1,然后函数就会返回。当我们再次调用这个函数的时候,它并不会从头开始执行,而是从yield的后一行,也就是bar2开始执行,一直运行到下一个yield,也就是执行完bar3,然后再次停止。也就是说,这个函数的执行状态是未执行——执行——挂起——继续执行——挂起——继续执行——返回。与我们正常函数的未执行——执行——返回有着一些区别。我们还可以从状态机的角度来看这个问题:

function foo() {
    let state = 0;
    stateMachine = () => {
        switch (state) {
            case 0:
                bar1();
                state = 1;
                break;
            case 1:
                bar2();
                bar3();
                state = 2;
                break;
            case 2:
                bar4();
                state = 3;
                break;
            default:
                return;
        }
    };
    return stateMachine;
}

上面的这个函数返回的是一个闭包,我们可以这样调用:

let stateMachine = foo(); // state = 0
stateMachine(); // state = 1
stateMachine(); // state = 2
stateMachine(); // state = 3

这里通过状态机,使我们的程序看上去真的有了暂时让出执行之后,继续恢复挂起的能力!

当然,协程的实现方法还有很多种,有有栈协程、无栈协程等。除了Go语言之外,大多数编程语言都选择的是无栈协程,往往把我们上述的generator编译成一种状态机来实现。

通过协程,我们的async/await机制似乎就容易理解了。await实际上就是一种yield的机制,把这个函数的执行权让出去。但是,这又和async有什么关系呢?我们来看上面用状态机实现的协程,会发现很难实现在层叠调用的时候的yield,比如说:

function somethingUnreal() {
    bar5();
    yield bar6();
    bar7();
}

function *foo() {
    yield bar1();
    bar2();
    yield bar3();
    bar4();
    somethingUnreal();
    return;
}

我们希望在somethingUnreal里的bar6yield能够停止foo的执行,然后恢复之后,继续执行bar7(),然后从somthingUnreal返回到foo里执行它后面的return语句。这用状态机似乎很难实现。所以,我们需要用async来做这个标记:

async function somethingReal() {
    bar5();
    await bar6();
    bar7();
}

async function foo() {
    await bar1();
    bar2();
    await bar3();
    bar4();
    await somethingReal();
    return;
}

通过这样具有感染性的async,就能正确地用状态机实现协程了。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值