socket编程:同步异步与阻塞非阻塞的辨析

1.socket io同步异步/阻塞非阻塞的概念和本质区别

异步操作相比同步操作可以提高应用的响应性和cpu的利用率,增大应用吞吐量。在socket编程中也存在异步io和同步io,再加上阻塞和非阻塞这些,这两组概念碰到一起,很容易让人混淆,搞不清楚同步异步和阻塞非阻塞这两组概念的本质区别。

其实在W. Richard Stevens的《UNIX网络编程》(1) 6章第6.2io模型中对这些讲的比较清楚了。

socket异步io unix/linux中使用aio*系列io方法(aio_readaio_write)才是异步socket iowindows中的完成端口之类的也是异步。异步当然是非阻塞的。为何叫异步:socket io读写方法(例如linux中的aio_readaio_write)和io的实际执行是独立的,分开/分离的:aio_read读方法和aio_write写方法只是将读写请求(指令)排队后就返回,这两个方法并不执行实际的io读写操作,也不关心实际的读写操作是否执行完成,实际的读写由操作系统在幕后执行。待读写操作有最终结果后,操作系统再通过回调方式或信号(singal)通知应用,告知先前的操作结果和数据;这个异步和我们在公司填请假电子流类似,提交请假申请就完事,工作流系统到时会发邮件告知请假审批结果。

socket同步io:调用recv/read send/write 方法的就属于同步socket io,因为在这些api方法中会执行实际的io操作,也就是读写方法和实际的io执行是绑在一起的。

那阻塞/非阻塞与同步/异步有啥区别?

这是两个不同的维度:

阻塞/非阻塞是指 io方法是否会阻塞线程,阻塞:如果socket读缓冲区为空,或缓冲区可读字节数少于你要读的字节数时读方法阻塞;如果写缓冲区满,或写的字节数大于写缓冲区的空闲字节数,此时执行写操作就会阻塞。非阻塞:不管是否能完成读写任务都不会阻塞,不能进行读写时立即返回(有相应的错误码),不会阻塞线程。在非阻塞情况下,写方法可能写出部分数据(部分字节),读方法读到的字节数可能少于要求的字节数。

具体来说就是如果socket不可读或不可写时(不可读:socket本地读缓冲区没数据或没有足够多的数据;不可写:写缓冲区满或空闲空间较少),此时执行io方法是否会阻塞线程;同步/异步是指io方法(api层面调用的方法)和io执行是否绑在一起。

 这两个维度合在一起可有四种组合:同步阻塞、同步非阻塞、异步阻塞、异步非阻塞。前面的请假例子中,请假的实际处理(领导审批)很显然不会成为阻碍你提交请假申请的一个因素,与此类似,实际情况中是没有异步阻塞这种组合的。采用异步io时,实际的io是在操作系统另外的线程中执行,从aio_readaio_write 这些异步api方法层面来看,它们只是提交了io请求或io申请而已,socket io的实际执行或socket是否可读可写并不会阻塞这些方法的执行。所以实际上只有三种组合:同步阻塞、同步非阻塞、异步非阻塞

2.多路复用

另外selectpollepoll之类的方法是为了在服务器端有效处理大量的网络io(例如大量的tcp连接)的情况下引入的多路复用机制,可用少量线程资源处理大量的tcp连接,可伸缩性好,解决C1xK问题会用到epoll。多路复用:可理解为悲观策略,先用selectepoll检测socket的读写状态(可读:socket本地读缓冲区中有数据。可写:本地写缓冲区有空闲空间),检测到可读或可写状态事件后,才派发事件:调用recv/read send/write这些同步方法发起读写操作,既然采用这些同步方法,那就仍是同步io,不过配合多路复用,这些读写方法一般是非阻塞模式,因为如果采用阻塞模式,即使检测到socket可读写后,后续的读写方法仍可能会阻塞住线程,例如检测到有可接受的tcp连接(可读)后,后续调用accept接受连接时,连接却中途被关闭或取消,此时accept就会阻塞,或者在数据socket上检测到可读,读缓冲区只有10个字节,但后续的读操作却要求读出大于10字节的数据,此时recvread就会阻塞。线程阻塞后,很可能会延误其他tcp连接上的读写,不符合多路复用的初衷。所以多路复用大多采用同步非阻塞模式。

多路复用情况下,存在平(水平)触发和边沿触发的概念,这两个概念来自数字电路的高低电平和脉冲的上升沿下降沿。可读可写可理解为高电平1,电平触发是只要socket高电平(可读可写),都会唤醒,最终会触发读写操作;边沿触发是上升沿(低电平变成高电平)才唤醒触发一次,如果后续仍为高电平,不会再唤醒触发,除非变成低电平后再变成高电平才行。

select是电平触发,epoll可支持电平触发和边沿触发。


   3.多路复用中的编程注意事项

多路复用中的电平触发编程较简单,但也有一些需要注意的地方,特别是在检测可写状态时,如果检测到可写,却没有需要写出的数据,那就会产生持续的多次触发,导致cpu负载过高,也浪费了cpu资源,因没有数据写出,因此采用电平触发时,一般采用按需检测可写状态事件的策略:只有当数据准备好需要写出时,才进行selectpollepoll的可写状态检测,写完后不要继续监测可写事件,如果单次写不完,可以放到缓冲区中,继续监测可写状态,待下次监测到可写时继续写出,或者循环多次写,直到写完为止(如果数据太多,可能对其他socket不公平),在循环写过程中,如果写方法返回不可写的错误码,将剩余的数据放到缓冲区,结束写,继续监测可写状态。在udp可写状态更要注意该问题,因为udp socket总是可写,当然tcp也要注意该问题,只是udp情况更严重。在写时要注意同步,防止有先前挂起的(pending)的写操作,在每个socket上应关联一个是否有挂起的写操作的状态标志,如有挂起的写操作时不能直接调用写方法,而是要将数据追加到应用层写缓冲区,后续的可写事件会把这些缓冲区数据写出。对读操作简单些,可以完全依赖可读事件来驱动实际的读操作,和写类似,可以单次读也可循环读,循环读(前面提到过多路复用一般采用非阻塞,循环读,直到读方法实际读出的字节数少于希望读的字节数或读方法返回相应的错误码指示没有数据了为止)

在边沿触发时,触发的次数少了,上升沿触发一次后,虽然后续仍保持可读可写状态也不触发了。在读写时,不能完全依赖边沿触发来驱动读写操作,特别是写操作,上层应用的写请求和底层socket的可写状态本来就是两码事,是独立的异步的。边沿触发模式的读写策略:对读,和电平触发的循环读相同,在检测到某个socket可读事件后,不考虑公平性,一直读(循环读),读到没数据为止(读方法实际读出的字节数少于希望读的字节数或读方法返回相应的错误码指示没有数据了为止);对写,先触发io线程直接写(要注意同步,防止socket上有挂起的写操作,如果有挂起的写操作就不能直接写,应将数据字节追加到应用层的写缓冲区,这些缓冲区的数据会由边沿触发的可写事件来启动写出操作),如果把数据全部写出,那ok完事,如果只写出部分数据(写方法的返回值大于零且小于需要写出的字节数),那就继续重复写剩余的字节数据,直到全部写出,如果在重复写的过程中返回错误码(为不可写导致的错误码),此时将剩余的数据字节追加到缓冲区,启动该socket的可写检测。当检测到可写后,触发io线程把写缓冲区的数据写出,这个仍是遵从前面的模式,重复写,写完后也不要继续监测可写状态。

可见无论是电平触发和边沿触发,对写都是按需检测可写状态事件,都要注意写时的同步。电平触发可采用单次读写或循环读写,对边沿触发一般是采用循环读写。边沿触发通常高效,但编程较复杂。

另外如果要支撑的tcp连接数较少,可采用每tcp连接一个线程或一个进程来进行处理,此时一般采用同步阻塞模式,编程简单。

猜你喜欢

转载自wanshi.iteye.com/blog/2374693