同步、异步、阻塞、非阻塞,以及IO模型的理解

同步和异步

同步 就是你知道你什么时候在做什么,做完一件事情再做下一件事情,因此主动权在自己手里。比如通过等待或轮询,你在某个时间点总是知道结果是怎样的(有数据还是没数据等)。
异步 就是你不知道什么时候会发生什么。比如你注册了多个回调函数,你不知道什么时候会被调用以及被调用的是哪一个回调函数。

阻塞和非阻塞

阻塞:一个调用过程必须完成才返回。对于IO操作,如果IO没有准备好,读取或者写入等函数将一直等待。
非阻塞:一个调用过程会立即返回,无论结果怎样。对于IO操作,读取或者写入函数总会立即返回一个状态,要么读取成功,要么读取失败(没有数据或被信号中断等)。
看微博上有人(屈春河的微博)说还有部分阻塞:整个调用过程分为C1,C2,…,Cn步,调用者完成C1,…,Cj步后就返回,而不是等待完成整个调用过程。

组合方式

通常有三种组合方式:
同步阻塞:所有动作依次顺序执行。单线程可完成。
同步非阻塞:调用者一般通过轮询方式检测处理是否完成。单线程可完成。
对于IO操作来讲,我们熟悉的select/epoll,即IO多路复用,可以认为属于这种工作方式。不过有人说select/epoll是异步阻塞的方式,这是为啥呢?明明select/epoll过程在用户态看来类似于轮询,而读写过程可以非阻塞的。实际上select/epoll过程是阻塞的,但它的好处是可以同时监听多个fd且可以设置超时,并且利用select/epoll的阻塞换取了读写的非阻塞。
异步非阻塞:Callback模式,注册回调,等待其他线程利用回调执行后续处理。Linux kernel里面有个aio就是异步非阻塞IO,但好像很多坑。

IO操作中的同步和异步

需要再重复一下几种IO模型:
1.阻塞I/O (blocking I/O):recv和recvfrom是阻塞的,即没有数据到来之前本线程不能做其他事情。
2.非阻塞I/O (nonblocking I/O):recv和recvfrom没有数据时也立即返回,但要一直进行recv/recvfrom并轮询返回值,浪费CPU。可以用fcntl来设置非阻塞:
int fcntl(int fd, int cmd, long arg);
例如:
sockfd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK);
3.I/O多路复用 (I/O multiplexing):select/poll/epoll,没数据时会阻塞,有数据的时候返回就可以进行recv了,它和阻塞I/O的区别是可以同时监听多个套接字描述符上的数据,有一个有数据就返回。而阻塞I/O是阻塞在recv那个地方,而且recv的时候已经指定某一个套接字了。
4.信号驱动I/O (signal driven I/O (SIGIO)):通过注册SIGIO的信号处理函数,来监听和接收数据(我没用过)。
5.异步I/O (asynchronous I/O):利用aio机制,设置存放数据的缓存、回调函数以及回调的方式(线程或信号等),并调用aio_read()读数据并立即返回。内核中准备好数据并拷贝完成后,通知用户态去读。

可见,这里并不是按照同步异步和阻塞非阻塞的组合给出的,我们也不必去纠结。但是异步IO模型和其他4种模型的一个显著区别在于:前4种模型最终调用recv去读,读的过程包括数据拷贝并返回状态,交给用户态处理;而异步IO在内核中默默地拷贝数据,记录状态,用户态省去了读操作,而是直接处理数据,这也是aio为什么需要预先分配读缓存的原因。
如果非要套用同步和异步的概念,那我认为是按照recv(将数据拷贝到用户态)的时机来区分的:对于异步IO,用户态并不知道什么时候recv,因为kernel已经帮忙做好了,而其他4中模型都是用户程序自己主动去recv的。

类比总线协议?

这让我想到了同步总线和异步总线,他俩一个明显区别是:
同步总线的通信双方总是通过一个统一的时钟来进行同步,比如串口,你首先要设置一个波特率,并且通信双方的这个值必须一致。也就是说两端都知道什么时候发送数据,什么时候能够期望收到对端的数据。某一方的时钟频率相差很大都会导致数据传输混乱。
而异步总线的通信协议中会要求某一端提供时钟(通常是master),例如I2C和SMI,并且并不要求时钟频率恒定。Master想发送数据了,才开始产生时钟,时钟间隔可以不固定。那么slave(可能有多个slaves,通过片选来确定数据要发送到哪个slave)只能在收到时钟以及一段opcode后才知道有数据来以及要做什么操作。
这实际上和软件上的同步、异步的概念类似。

再说IO模型

再说说各种IO模型的适用场景。

由于现实中场景复杂,因此需要根据不同场景选择合适的IO模型。IO过程主要是硬件上操作时间比较长,数据传输通常又有DMA,所以IO过程中CPU消耗并不大。所以线程做IO时CPU可以做别的事情,因而不适宜让IO将CPU阻塞,我们选择IO模型的目标就是在保证IO正常的前提下尽量提高CPU利用率。

上面的几种IO模型中:阻塞、非阻塞、异步IO(aio),通常用于块设备读写,IO传输效率和CPU利用率是主要要评估的点。而多路复用(包括事件通知机制libevent等)、signal IO则常用于字符设备或网络socket。因为它们更多的是监听事件,考虑是否能及时收到并正确处理事件。

例如网络发包时,如果组包、发包的过程放在一个线程里串行的做,那么如果发包buffer满了就只能等待buffer可用才能把新的包放下去,这样就没办法完全利用CPU。可以用一个线程组包以及做其他事情,另一个线程发包,或者发包用非阻塞方式,看到buffer满就先返回做一些其他事情,再尝试重发。

阻塞IO:内核程序在等待一个IO时,如果读不到东西,就阻塞了(内核会通过set_current_state(TASK_INTERRUPTIBLE)让线程设置为可中断,表示你要等待某事件或资源而睡眠,然后调用schedule放弃CPU),直到IO准备好了读到东西返回,或者被信号中断返回。如果是被信号中断返回的,会伴随错误码EINTR

阻塞IO对于在一个线程中存在并发IO操作或者需要监听多个设备时就不适合。当然你可以通过多线程或多进程来分别处理每种IO操作或每个socket,编程也简单,但创建线程本身有资源开销,并且存在任务切换和任务通信的开销,效率不高。

非阻塞IO:纯碎的非阻塞一般不用,因为每次去无脑地轮询看起来很傻。所以都是和select/epoll结合用的,等到数据ready了再用非阻塞去读。
用非阻塞时(在读时设置MSG_DONTWAIT或者用fcntl设置F_SETFL为O_NONBLOCK),如果没有数据了你也要任性去读的话,会直接返回EAGAIN,即Resource temporarily unavailable。

多路复用:就是一个线程中处理多个IO,例如select和epoll,它们可同时监听多个IO事件,有一个事件发生就返回,处理完这个事件后继续监听。实际上监听的过程是阻塞的,但比阻塞IO的好处是可以在一个线程里同时监听多个IO事件。

select把所有fd都加到一个集合(set)里面,然后监控这个set,有一个fd发生事件就返回,这个监控的过程在内核中是通过遍历set,并且select返回后,你在用户态也要遍历所有fd列表来查找是listenfd还是接入的fd产生的事件以及哪个接入fd的事件,因此整个过程要两次遍历。另外,每次select都要重新设置整个set到内核里,前期准备工作也耗时。select在Linux里的fd数量限制由__FD_SETSIZE定义,一般是1024。

epoll解决了select处理fd集合的上述两个问题,它把设置set和等待事件的api分离(epoll_ctl和epoll_wait),不用每次等待前都设置set;另外epoll_wait的第二个参数会保存哪些fd产生事件了,因此不用扫描整个set列表。这样就可以更高效地监听更多的事件。
epoll_wait等到事件后不要做太多事情,防止又有新的fd准备好了。所以epoll也会结合线程池等设计,将事件的处理放到任务队列里去,然后尽快重新epoll_wait。

但是和select相比,为了解决上述问题epoll引入了监控fd的红黑树,和ready事件的list,这些是额外的开销。

这里也说一下多路复用时对信号的处理:
select从内核返回到用户态之前会检查是否有未处理的signal,如果有signal pending,也就是被信号中断了,就会返回-ERESTARTSYS到标准库(我实际看到的是ERESTARTNOHAND)。有人给你kill了信号,你当然要处理这个信号啊,因此会执行信号处理函数,然后会确定该系统调用是返回到用户程序还是重新执行这个被打断的系统调用。
这是根据这个信号的处理是否设置了SA_RESTART标记来决定的,如果设置了SA_RESTART,则系统调用被信号打断,CPU转而执行信号处理函数完成后,系统调用会重新执行;而如果不设置SA_RESTART标记,则执行完信号处理函数后就返回到用户程序了。至于信号是否默认设置了SA_RESTART,可以通过strace去跟一下,例如对于信号SIGINT默认不设置这个标记。

例如阻塞的方式调用read()/recv()的时候,假如sigaction的时候对信号设置了SA_RESTART标记,那么如果read过程中被该信号打断,执行完信号处理函数后,read会继续进入内核执行继续阻塞而不是返回。而如果信号没设置SA_RESTART的话,在调用完信号处理函数之后,read立即返回,read返回-1并设置出错码为EINTR

在select和read的时候都需要注意检查这个错误码看是不是被信号中断而返回的。

自动忽略SA_RESTART的系统调用
然后对于一些对超时时间敏感的系统调用,如select/usleep,会忽略信号的SA_RESTART标记,无论是否设置该标记,总是返回到用户程序。例如sleep,被信号中断后就返回,不管你是否给这个信号设置了SA_RESTART标记。因为你sleep(5)想睡5s,结果睡2s后被中断,不可能自动重发再睡5s。

另外说一下,每次select完之后,参数tv会被赋值为尚未等待的时长,例如设置5s,但select等了2s就返回了,那tv中保存3s。nanosleep()也是一样,它可能在睡眠过程中被信号打断而返回,但你可以通过检查nanosleep没睡够的时间让睡眠更精确一些。

signal()函数的man page中说明了有哪些系统调用,即使你设置了自动重发(SA_RESTART)也不会自动重发。

The following interfaces are never restarted after being interrupted by
a signal handler, regardless of the use of SA_RESTART; they always fail
with the error EINTR when interrupted by a signal handler:

   * Socket interfaces, when a timeout has  been  set  on  the  socket
     using   setsockopt(2):   accept(2),   recv(2),  recvfrom(2),  and
     recvmsg(2), if a receive timeout (SO_RCVTIMEO) has been set; con‐
     nect(2),  send(2),  sendto(2),  and sendmsg(2), if a send timeout
     (SO_SNDTIMEO) has been set.

   * Interfaces used to wait  for  signals:  pause(2),  sigsuspend(2),
     sigtimedwait(2), and sigwaitinfo(2).

   * File    descriptor    multiplexing   interfaces:   epoll_wait(2),
     epoll_pwait(2), poll(2), ppoll(2), select(2), and pselect(2).

   * System V IPC interfaces: msgrcv(2), msgsnd(2), semop(2), and sem‐
     timedop(2).

   * Sleep    interfaces:    clock_nanosleep(2),   nanosleep(2),   and
     usleep(3).

   * read(2) from an inotify(7) file descriptor.

   * io_getevents(2).

The sleep(3) function is also never restarted if interrupted by a  han
dler,  but  gives  a success return: the number of seconds remaining to
sleep.

signal IO:现在基本没人用了,因为用它的程序架构不好看,有点过度设计了。它就是设定一个特定的信号SIGIO的处理函数。内核发现事件(具体什么fd什么事件内核里面自己实现)后就kill一个SIGIO信号给用户程序,然后在信号处理函数中做事情。

异步IO:即aio。aio有两个版本,glibc的aio,以及kernel里的纯碎的异步aio机制。即发起一个IO后,我不需要原地等,后台会在IO ready之后帮你读到指定的数据区,当我在某一个点上需要同步等待它读完的时候,就调用xxx_suspend。也就是说,数据的读取是内核或glib帮你做的,并且你可以在任何你需要数据的时候再去等待IO。

glibc的aio,你调用aio_read会立即返回,glib开一个后台线程帮你同步去读,当你运行到某个点确实需要等待数据读完时,你调用一个aio_suspend来等待就可以了(当然如果这时数据已经读出来了,aio_suspend就立即返回了)。

kernel里的aio机制,用户态接口为io_setup/io_submit/io_getevents/io_destroy,这几个API分别用来准备上下文、类似aio_read发布IO请求的过程、类似aio_suspend的同步等待过程、销毁上下文。使用时要加-laio。
kernel的aio通常跟O_DIRECT配合用,例如有些人想在用户态自己做数据缓存,而不用内核的page cache,就用O_DIRECT打开硬盘,用aio读数据。使用kernel的aio时,由于kernel里面的aio还不是很完善,进化的比较慢,目前只用O_DIRECT打开文件才能用kernel的aio

glibc_aio和kernel_aio接口的用法见man page。

事件触发: libevent利用epoll做了一层封装,做成基于异步事件通知的IO模型,它实际上就是让你先event_add注册事件处理函数,然后dispatch里面不断的去epoll_wait,有事件发生就调用对应的callback函数。我觉得就类似minigui里面的按钮事件的proc函数一样,你点了按钮就调用预先注册好的按钮事件处理函数。并且libevent是跨平台的,防止epoll在别的系统上没有。

你可能感兴趣的:(Linux编程)