网络编程基础概念

2014-11-09

同步异步/阻塞非阻塞

很容易混淆的基本概念,新手总会经历的阶段。网上也有许多科普的文章。只是谈谈自己的理解,并不比其它人写得好。

我觉得理解这两个概念,就是要区分他们关注的点是不同的。从参与方的角度理解阻塞非阻塞,它们是只需要一方参与的。 操作不能继续执行下去就让进程挂起的,就是阻塞的。 而操作不能继续执行下去,只是返回出错或者某种形式告知这个操作执行不了,但是进程还是可以处理错误并继续运行的,就是非阻塞的。 强调的是只考虑参与方,不要纠结于操作是什么。操作可以是请求网络资源,或者操作的是写文件,甚至是申请内存操作。我们现在的系统,申请内存失败,会返回NULL给调用者,这就是非阻塞的。假设如果有某个实现,申请内存失败就把进程挂起,直到某个时刻系统有足够内存了再唤醒进程,这种方式就是阻塞的。

同步异步的参与者肯定要有两方。一方向另一方发起请求,发起方要等待直到请求返回就是同步的工作流程。而如果发起请求方在请求发出后可以去做别的事情,请求会在未来某时刻以某种方式通知到请求方,这种形式就是异步的。这里强调的是两方参与。比如js回调,就是异步方式。

io模型

unix网络编程中总结了5种IO模型,推荐去看下原文

  • 阻塞式IO
  • 非阻塞式IO
  • IO复用
  • 信号驱动式IO
  • 异步IO

对于一个network IO,以read举例,它会涉及到两个系统对象,一个是调用这个IO的进程或线程,另一个就是系统内核。当一个read操作发生时,它会经历两个阶段:

  1. 等待数据准备
  2. 将数据从内核拷贝到进程中

第一个阶段是发生在内核中的。阻塞式IO就是第一阶段阻塞,非阻塞IO在第一阶段调用者不阻塞,而是告诉它操作不能完成。IO复用留到下面谈。

信号驱动式IO跟非阻塞式IO区别是,后者必须不停的去试,问操作系统,好了没有?好了没有?好了没有...而后者是由内核向进程发送“好了"的信息,期间进程可以去做别的事而不必要一遍遍地问。

最后异步IO,应用跟内核发个请求,内核把第1阶段和第2阶段都做好了就去通知应用。严格地讲,只有它是真正异步的,整个IO过程都是委托给另一方完成。信号驱动式IO第二阶段还是要自己做,所以完整的IO过程不能算异步的。

从同步异步角度,只有5是异步的。

从阻塞非阻塞角度,1和3是阻塞的,其它都是非阻塞的。因为只有1和3会让操作执行不下去。

IO复用

前面说了5种IO模型,真正好用的,只有第一种。而真实世界中实际使用的,只有第三种,反正其它的我是没看到用过。

阻塞式IO如果阻塞的是进程/线程,都代价太高了,主要是内存开销,其次是对调度带来的影响。

非阻塞式IO由于要轮询,代价太高,主要是CPU时间片。后面两种,不知道是不是因为太复杂而没流行起来。为什么呢?其实我也不知道为什么...

只剩下第3种,IO复用。IO复用是让一个进程/线程去做阻塞操作,管理多个IO。如果这些IO中有一个可用了,就唤醒调用者。最早的提供的IO复用是select和poll函数,后来有了kqueue,epoll出生应该比较kqueue晚一点。

APUE/UNP几本经典的书,内容都有缺失,像pthread,kqueue,epoll这些主题都是晚于那本书的年代的。可惜stevens死得太早...

select

select(int nfds, fd_set *r, fd_set *w, fd_set *e, struct timeval *timeout)

使用select(),应用需要提供三个感兴趣集合r,w和e。每个集合都由一个bitmap表示。比如需要关注文件描述符6是否可读,则将r的第6位置为1。select()会阻塞调用者,直到至少有一个集合中指定的文件描述可用。每次select()返回时,内核都会重写bitmap中的标记位来指示哪些文件描述符是就绪的。

select的问题:

  1. bitmap大小固定(通常是1024)
  2. 内核通过置位bitmap通知应用,而应用每次都需要在下一轮之前重置位一遍
  3. 每次调用应用都需要扫描整个bitmap,以确定哪些文件描述符可用
  4. 内核也是每次调用都需要描述整个fd_set,以确定用户对哪些文件描述符感兴趣

poll

poll跟select的工作方式很类似,只不过提供的接口略有不同。

poll(struct pollfd *fds, int nfds, int timeout)

struct pollfd {
    int fd;
    short events;
    short revents;
}

由于poll()不依赖bitmap,而是文件描述符的数组,因此解决了select中的问题1。而通过将输入和结果分离在不同的结构体域中,select的问题2也解决了。

无论是select还是poll,在大量连接都存在可扩展性问题。假设有10,000个并发连接,常常是,每次其中只有10个是就绪的,但select/poll还是要每次都拷贝数据的扫描数据,其中9,990个操作都是废的,毫无道理。高并发场景下,任何一个细小的开销都会被放大许多倍。这样大量浪费的cpu导致服务器性能根本起不来。

其实自己想一下,解决方式并不难。不要用数组,用链表就好了。这样就不需要内核扫描整个数组,设置就绪的那些,应用再扫描整个数据,确定可用的那些。

epoll和kqueue

为了解决上面的问题,freeBSD和linux分别使用了不同的方法。linux是epoll,freebsd是kqueue。

int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

可以将struct epoll_event想象成这样的结构(虽然实际上不是):

struct epoll_event { 
        unsigned long id;   /* file descriptor ID the event is on */ 
        unsigned long event;    /* bitmask of active events */ 
};

那么用户要告诉内核对哪个文件描述符的什么事件感兴趣,只需要设置这个结构体。如果要表达对这个文件描述符不再感兴趣了,只需要将event清零了再调一次epoll_ctl。而服务器通知时返回了maxevents,不需要遍历整个的数组。

kqueue中kevent担当了跟epoll_ctlepoll_wait两个函数的角色。

int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges, 
           struct kevent *eventlist, int nevents, const struct timespec *timeout);
同步阻塞io模型selectepoll网络编程