孩子们总分不清楚同步(synchronized)和异步(asynchronized)究竟有什么区别,实际上,两者仅仅是相差一个字母“a”,并不是什么大不了的事情,除了,这个“a”是“not”的意思。
同步和异步,在不同的场合下,实际上代表着完全不同的含义。通常可以区分为两件事:协议和实现。
协议:针对连接。一个请求被回应之前,是否可以发出第二个请求?如果必须一问一答,那么这个协议一定是同步的。同步协议最大的问题就在于网络延迟,为了能够加大吞吐量,就必须并发请求,就必须维持大量连接。而异步协议允许请求和回应以任意顺序发送,不要求有序,不要求一一对应,基本上什么都不要求。异步协议需要会话管理来追踪请求超时和乱序回应等问题,但是可以在单连接上就跑出最大效能,也就不会有爆TIMEWAIT什么的。
实现:针对线程。一个线程被一个请求占用之后,在这个请求的回应包被完全生成之前,这个线程可以转而去处理另一个请求吗?这个问题主要是在于生成回应包时可能需要的资源并未就绪,因此必须挂起业务代码。如果可以插入其他请求,就是异步的,否则线程就必须挂起,发生上下文切换。同步实现的最大好处就是省心,开发速度快而质量很高,但是运行效率嘛。
仔细分析上面两段话就可以发现一些有趣的东西。比如nginx都快是高性能的代名词了,但是因为HTTP协议是同步的,不管nginx内部速度有多快,你一条连接都跑不起来多少qps,要压力测试nginx必须开个几千连接并发压。又比如redis,如此高效而且是单线程的程序,大概是异步实现吧?其实,这货是完全的同步实现,只是因为内部内存效率太高,处理完一个请求基本不耗时,排队做就行了。
协程是一个妖孽,因为这货看起来好像是同步实现,但是其实不知不觉给做成了异步实现。当然,不管框架如何妖,既然本质是异步实现,就不单独讨论了。
怎么设计(适配)一个程序,主要就是在思考怎么设计(适配)协议的同步性和实现的同步性。如果你有得选,协议一定得是异步的,无他,网络延迟造成的影响跟维护会话所需要的CPU相比,实在是太太太大了。而实现却需要思考一下,看看是不是有不能异步化的限制(比如用了一个同步的SDK,说的就是你libmysqlclient)或者是生成回应所需要的状态机步骤过多,会使得代码开发变得巨漫长而bug满天飞(说的还是你,libmysqlclient)。
一般在进行程序开发的时候,可以细分为8种情况,或者16种情况,甚至32种情况,显然这是一个2的幂,就像这样的8种情况:
对外协议 | 内部实现 | 依赖协议 | 对策 | |
1 | 同步 | 同步 | 同步 | 一个连接一个线程是最合理的做法,做原型测试挺好的。PHP用curl调另一个PHP就是这样的,开发巨快,效率巨低。 |
2 | 同步 | 同步 | 异步 | HTTP后挂fastcgi然后调用远程的thrift服务就是这个样子的。仍然是一个连接一个线程,不过访问后端只需要一个连接。典型的如thrift client,会自动挂起没有得到回应的线程,根据收到回应或者超时来唤醒对应线程。 |
3 | 同步 | 异步 | 同步 | 因为前后端都是同步协议,所以前后端肯定都是大量连接,但是由于是异步实现,中间只需要少量线程,一个可能就够了,然后两侧分别多路复用。工作在HTTP-HTTP代理模式下的nginx就是这个样子。 |
4 | 同步 | 异步 | 异步 | nginx工作在HTTP-HTTP2代理时就是这个样子。前端N个连接,后端一个连接就做完了。 |
5 | 异步 | 同步 | 同步 | 一个请求一个线程是最合理的,然后需要在对外侧准备一个请求的分发器和收集器,收到请求找一个线程去做,做完了再发回去。HTTP2后面跑PHP就是这样。如果你要操作mysql,基本跑不掉这个模式。 |
6 | 异步 | 同步 | 异步 | HTTP2后面挂fastcgi跑thrift client? |
7 | 异步 | 异步 | 同步 | nginx工作在HTTP2-HTTP代理时就是这个样子。前端一条连接跑个多会话,后端就只能打很多连接出去了。 |
8 | 异步 | 异步 | 异步 | 性能绝对无敌。鉴于开发难度,作为异步-异步代理是最合适的,如果中间带了复杂状态机,真的是会掉头发的。 |
实际情况下,前后端都有可能不是协议直接暴露给业务的,有一些情况是通过SDK进行了封装。不管封装了之后跟协议本身属性是否一致(同步协议给同步SDK,异步协议给异步SDK),都可能造成麻烦。
thrift client,上表多次提到。这是一个很有趣的SDK,因为thrift协议本身是双向异步,但是官方只提供了nonblocking的server版(实际业务还是同步做的),而client必须是同步的。它的做法就是连接尽量复用,你发起调用时,存异步状态机之后把线程挂起(好暴力),后面有一个线程按照标准的异步写法去和后端通信。后端如果回包了,或者某个包超时了没回来,找到应该唤醒的线程,唤醒它并且返回信息。这个设计的主要原因就是简单,业务代码直接同步写法,不需要thrift开发很多API来支持异步。
其实,异步协议同步化还有个最简单办法,就是开N条连接,每条只发一个请求,然后sleep等回包。PHP调用异步协议这么做还真挺简单的,什么?开销?我都用PHP了我管什么开销?
libmysqlclient则是另一种情况。本来mysql协议就是同步的,不过我们仍然可以通过状态机来单线程带动多个连接,这样就可以通过打出大量后端连接但是业务实现不用大量起线程。但是官方的libmysqlclient是发现I/O挂起了就直接把线程挂起了,不给你机会切换到另一条连接接着做。理由同thrift,API简单啊。
有没有搞怪的SDK,把同步协议暴露成异步的呢?当然有,比如libcurl。HTTP是同步协议,那么做一个连接(请求)管理器来管理很多条连接就好了。libcurl的multi接口就是你可以非阻塞地操作N个同步连接,让你感觉好像HTTP也是异步似的。
如果我们遇到了一个异步SDK,想要把它同步化,那么学thrift的做法做一些挂起管理就好了,基本没开销(相比于业务代码而言)。反过来就麻烦了,因为SDK会迫使线程挂起,因此必须开一大堆线程去顶,也就是把同步协议那种情况开多连接,直接换成开多线程,然后在线程上做会话管理。这个是有开销的,而且和业务代码相比,这个开销不容小视。知道我为啥讨厌libmysqlclient了不。
不过说句公道话,操作mysql的状态机之复杂,一个事务从建立连接开始动不动就是10句SQL,就把程序分割成了11个以上的片段,这种状态机写起来,恐怕还真不如同步实现算了。也正是因为这个,协程才开始兴起,协程版的libmysqlclient也受到了比较多的关注。Facebook做了个异步版的libmysqlclient,我不看好,不是不看好库的质量,只是不看好写这么复杂状态机的程序员。
如果我们把SDK的情况也添加到表格里,就会得到一个16行的表,甚至32行的表,显然,这不是什么好主意,读者自行脑补好了。