回顾之前说过的,服务和服务之间的调用,可以分为同步调用(发起方等待结果)和异步调用(发起方不等待结果),同步调用的好处是写代码简单,坏处是有可能阻塞线程,造成线程资源浪费。我这里说“有可能”,是因为可以使用支持io异步的编程语言,来避免线程阻塞。
如果服务间调用使用异步的流程,当然可以彻底避免线程阻塞,一般来说又分两种实现场景。
- 回调方式
A调用B,A继续其他工作,B完成后,调用A,告诉计算结果。这种方式效率更高,但要求A提供被调用的api - 轮询方式
A调用B,A每隔一段时间调用B提供的结果查询api,结果查询的api通常返回任务处理的进度或结果。这种场景下,B不需要调用A,所以A的实现更简单。
不管使用了哪种方式,都需要有超时补偿机制,通常是一个定时执行的job,来找出那些发出请求很久,却还没有收到结果的请求。
刚才说的“同步”或者“异步”,我称之为“流程”,是指服务的设计思想,微观到每一次服务的调用,其实都是“同步”的,因为服务调用,总要等到那次调用返回的结果。同步流程下,服务调用返回结果就是计算结果,异步流程下,服务调用返回的是“我已经开始处理你的请求”这样的消息。
到现在才讲到今天的主题,今天讲的是被调用的这个服务(进程)的内部,有哪几种“同步”或“异步”的多线程编程模式。下面所说的“线程”和“任务”,都是指运行在线程池上的一段代码,而不是指操作系统线程。避免程序员手工创建线程,是异步编程计算机语言的重大贡献。
我试图在概念上从大到小,把多线程的模式说清楚。
通常服务收到一个调用请求,就会为这个请求提供一个线程来服务,如果同时收到许多个请求,就会有许多个同时运行的线程来分别服务这些请求,这是基本的多线程模型。现在我们只考察其中的一个线程。
在一个服务请求的线程中,最大的区分维度是上面提到的“流程”。
- 在“同步流程”下,收到请求的线程立刻投入计算,执行“一段代码”,把计算结果返回给调用方。
- 在“异步流程”下,收到请求的线程创建一个计算任务,由那个任务去执行那“一段代码”,创建完任务之后,不等任务执行完成,立刻返回给调用方,通常返回的信息代表“我已经开始处理你的请求”。
下一个考察维度是那“一段代码”内部,有没有可能的“异步模型”,我总结有四种情况
- 完全顺序执行,没有异步。那“一段代码”的执行,从开始到结束占用一个线程
- 存在IO异步。我们知道,磁盘操作和网络操作的速度,相对于CPU(执行线程)的速度,是非常慢,如果线程执行到磁盘操作和网络操作,等待操作结果,会大大浪费线程资源。理想状态下,线程执行到磁盘操作或网络操作时,能够交出控制权,把线程资源给别的任务去使用,等到磁盘操作或网络操作的结果返回,当前任务可以被唤醒,拿到线程资源继续执行。这个资源调度的方法操作系统是支持的,windows下叫做“IOCP”,linux下叫做“epoll”,支持io异步的编程语言,比如F#和go语言,对IOCP或者epoll进行了封装,可以用顺序编程的语句,实现io异步编程。如果编程语言不支持,需要编写底层函数,同时代码中会出现大量回调函数
- 任务可分解。“一段代码”内部存在可并发的逻辑,可以创建多个任务同时执行,主线程收集每个任务返回的结果,整段代码的执行时间取决于最慢的那个子任务的执行时间,这种模式有利于充分利用cpu多核资源,缩短当前任务执行的总时间。
- 单例模式。“一段代码”内部访问了有竞争的资源,不可以与同类的代码同时执行,这时进程内使用了actor模型,会有一个线程专门管理那个竞争资源,代码需要给那个actor发消息,等待actor的执行结果。也就是在这个模式下,当前线程需要与一个已经存在的线程交换消息。线程间的消息传递,最好也需要编程语言的支持,所以现在开始,是时候放弃那些不支持线程间消息传递的语言了