多路复用机制,是的可以同时监听多个套接字连接。IO多路复用是指内核一旦发现进程指定的一个或者多个IO条件准备读取,它就通知该进程。IO多路复用适用如下场合:
(1)当客户处理多个描述字时(一般是交互式输入和网络套接口),必须使用I/O复用。
(2)当一个客户同时处理多个套接口时,而这种情况是可能的,但很少出现。
(3)如果一个TCP服务器既要处理监听套接口,又要处理已连接套接口,一般也要用到I/O复用。
(4)如果一个服务器即要处理TCP,又要处理UDP,一般要使用I/O复用。
(5)如果一个服务器要处理多个服务或多个协议,一般要使用I/O复用。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
6.1非阻塞方式
6.1.1阻塞与非阻塞
阻塞: 阻塞调用是指调用结果返回之前,当前线程会被挂起。该进程被标记为睡眠状态并被调度出去。函数只有在得到结果之后才会返回。当socket工作在阻塞模式的时候,如果没有数据的情况下调用该函数,则当前线程就会被挂起,直到有数据为止。
非阻塞: 非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。epoll工作在非阻塞模式时,才会发挥作用。
6.1.2非阻塞Socket
正常情况下,socket默认工作在阻塞模式下,在调用accept,connect,read,write等函数时,都是阻塞方式,直到读到数据才会返回。但是,如果将socket设置为非阻塞状态,那么这么些函数就会立即返回,不会阻塞当前线程。
设置非阻塞socket的方法是:
int SetNonBlock(int iSock) { int iFlags;
iFlags = fcntl(iSock, F_GETFL, 0); iFlags |= O_NONBLOCK; iFlags |= O_NDELAY; int ret = fcntl(iSock, F_SETFL, iFlags); return ret; } |
6.1.3非阻塞accept
tcp的socket一旦通过listen()设置为server后,就只能通过accept()函数,被动地接受来自客户端的connect请求。进程对accept()的调用是阻塞的,就是说如果没有连接请求就会进入睡眠等待,直到有请求连接,接受了请求(或者超过了预定的等待时间)才会返回。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); |
返回值是一个新的套接字描述符,它代表的是和客户端的新的连接,可以把它理解成是一个客户端的socket,这个socket包含的是客户端的ip和port信息。失败返回-1, 错误原因存于errno 中。之后的read和write函数中的fd都是指这个new_fd。
阻塞模式下调用accept()函数,而且没有新连接时,进程会进入睡眠状态。
非阻塞模式下调用accept()函数,而且没有新连接时,将返回EWOULDBLOCK(11)错误。
可以用以下代码来测试:
int SetNonBlock(int iSock); int main(int argc, char* argv[]) { int listenfd, connfd;
struct sockaddr_in serveraddr; struct sockaddr_in clientaddr; socklen_t clilen;
listenfd = socket(AF_INET, SOCK_STREAM, 0);
SetNonBlock(listenfd);
//listenfd绑定ip地址 bzero(&serveraddr, sizeof(serveraddr)); serveraddr.sin_family = AF_INET; char local_addr[20]="127.0.0.1"; inet_aton(local_addr,&(serveraddr.sin_addr)); serveraddr.sin_port=htons(8000);
//bind和listen不是阻塞函数 bind(listenfd,(sockaddr *)&serveraddr, sizeof(serveraddr)); listen(listenfd, 20); cout << "server listening ..." << endl;
int ret = -1; while(1) { connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen);//以后读写都用这个返回的fd cout<<"connfd = "<<connfd<<", errno = "<<errno<<endl; sleep(1); } return 0; } |
如果设置为非阻塞,accept会立即返回,打印出错误信息,errno=EWOULDBLOCK,表明此处本来应该阻塞,有新的连接时,连接会成功;
如果不设置非阻塞,进程就阻塞在accept那里,直到有新的连接到来。
6.1.4非阻塞connect
在阻塞模式下,客户端调用connect()函数将激发TCP的三路握手过程,但仅在连接建立成功或出错时才返回。
非阻塞工作模式,调用connect()函数会立刻返回EINPROCESS错误,但TCP通信的三路握手过程正在进行,所以可以使用select函数来检查这个连接是否建立成功。
源自Berkeley(BSD)实现有两条与select函数和非阻塞相关的规则:
l 当连接建立成功时,描述字变成可写。
l 当连接建立出错时,描述字变成即可读又可写。getsockopt()函数的errno == 0表示只可写。
注:
* 如果select调用之前,连接已经建立成功,并且有数据发送过来了,这时套接字将是即可读又可写,和连接失败时是一样的。所以我们必须用getsockopt来检查套接字的状态。
* 如果我们不能确定套接字可写是成功的唯一情况时,我们可以采用以下的调用
(1)调用getpeername,如果调用失败,返回ENOTCONN,表示连接失败
(2)调用read,长度参数为0,如果read失败,表示connect失败。
(3)再调用connect一次,其应该失败,如果错误是EISCONN,表示套接字已建立而且连接成功。
* 如果在一个阻塞的套接字上调用的connect,在TCP三路握手前被中断,如果connect不被自动重启,会返回EINTR。但是我们不能调用connect等待连接完成,这样会返回EADDRINUSE,此时我们必须调用select,和非阻塞的方式一样。
处理非阻塞 connect 的步骤:
(1)创建socket,并利用fcntl将其设置为非阻塞
(2)调用connect函数,如果返回0,则连接建立;如果返回-1,检查errno ,如果值为 EINPROGRESS,则连接正在建立。
(3)为了控制连接建立时间,将该socket描述符加入到select的可写集合中,采用select函数设定超时。
(4)如果规定时间内成功建立,则描述符变为可写;否则,采用getsockopt函数捕获错误信息。当errno == 0表示只可写。
<实例>
Redis客户端CLI (command lineinterface),位于源代码的src/deps/hiredis下面。
实际上,不仅是Redis客户端,其他类似的client/server架构中,client均可采用非阻塞式connect实现。
https://github.com/redis/hiredis/blob/master/net.c
参考函数:_redisContextConnectTcp()
当然,也可以用poll或epoll来代替select。
非阻塞模式 connect() + select()代码:
int RouterNode::Connect() { sockaddr_in servaddr = {0}; servaddr.sin_family = AF_INET; inet_pton(AF_INET, ip_.c_str(), &servaddr.sin_addr); servaddr.sin_port = htons(port_);
int ret = ::connect(fd_, (struct sockaddr *)&servaddr, sizeof(servaddr));
if(ret == 0) {//连接建立成功 is_connected_ = true; return 0; }
int error = 0; socklen_t len = sizeof (error);
if(errno != EINPROGRESS) { goto __fail; }
fd_set wset;//写集合 FD_ZERO(&wset); FD_SET(fd_, &wset);
struct timeval tval; tval.tv_sec = 3;//3s tval.tv_usec = 0;
if (select(fd_ + 1, NULL, &wset, NULL, &tval) == -1) { //出错、超时,连接失败 goto __fail; }
if(!FD_ISSET(fd_, &wset)) {//不可写 goto __fail; } // 如果连接成功,此调用返回 0 if (getsockopt(fd_, SOL_SOCKET, SO_ERROR, &error, &len) == -1) {//获取socket选项失败 goto __fail; }
if(error) { goto __fail; }
is_connected_ = true; return 0;
__fail: close(fd_); return -1; } |
6.1.5非阻塞write
对于写操作write,非阻塞socket在发送缓冲区没有空间时会直接返回-1。错误号EWOULDBLOCK或EAGAIN,表示没有空间可写数据;如果错误号是别的值,则表明发送失败。
如果发送缓冲区中有足够空间或者是不足以拷贝所有待发送数据的空间的话,则拷贝前面N个能够容纳的数据,返回实际拷贝的字节数。
而对于阻塞Socket而言,如果发送缓冲区没有空间或者空间不足的话,write操作会直接阻塞住,如果有足够空间,则拷贝所有数据到发送缓冲区,然后返回。
<实例>
/** * 返回-1:失败 * 返回>0: 成功 */ int WriteNonBlock(int fd, const char* send_buf, size_t send_len){ int sentlen = 0;//已经发送的长度 while(sentlen < send_len){ int ret = write(fd, send_buf+sentlen, send_len-sentlen); if(ret <= 0){ if(ret < 0 && errno == EINTR){ continue; } else{//遇到EAGAIN直接退出 break; } } sentlen += ret; } return sentlen; } |
6.1.6非阻塞read
对于阻塞的socket,当socket的接收缓冲区中没有数据时,read调用会一直阻塞住,直到有数据到来才返回。
l 当socket缓冲区中的数据量小于期望读取的数据量时,返回实际读取的字节数。
l 当sockt的接收缓冲区中的数据大于期望读取的字节数时,读取期望读取的字节数,返回实际读取的长度。
对于非阻塞socket而言,socket的接收缓冲区中有没有数据,read调用都会立刻返回。
l 接收缓冲区中有数据时,与阻塞socket有数据的情况是一样的,如果接收缓冲区中没有数据,则返回-1,
l 错误号为EWOULDBLOCK或EAGAIN,表示该操作本来应该阻塞的,但是由于本socket为非阻塞的socket,
因此立刻返回,遇到这样的情况,可以在下次接着去尝试读取。如果返回值是其它负值,则表明读取错误。
<实例>
/** * 返回-1:失败 * 返回>0: 成功 */ int ReadNonBlock(int fd, char* recv_buf, size_t recv_len){ int readlen = 0;//已经读到的长度 while(readlen < recv_len){ int ret = read(fd, recv_buf+readlen, recv_len-readlen); if(ret == 0){//已到达文件末尾 return readlen; } else if(ret > 0){ readlen += ret; } else if(errno == EINTR){ continue; } else{//遇到EAGAIN直接退出 break; } } return readlen; } |
recvfrom,sendto等函数也是同样类似的方法。
6.1.7非阻塞recv/send
介绍另一种实现非阻塞的方法,这种方法在有些应用中会起到一定作用,尤其是在select()函数监听的套接字个数超过1024个时(因为fd_set结构在大部分UNIX系统中都对其可以监听的套接字个数作了1024的限制,如果要突破这个限制,必须修改头文件并重新编译内核),我们就不能使用select多路复用机制。
如recv()函数,我们可以这样进行调用:
recv(fd, buf, sizeof(buf), MSG_DONTWAIT); |
注意到这里采用了MSG_DONTWAIT标志,它的作用是告诉recv()函数如果有数据到来的话就接受全部数据并立刻返回,没有数据的话也是立刻返回,而不进行任何的等待。采用这个机制就可以在多于1024个套接字连接时使用for()循环对全部的连接进行监听。
recv设置MSG_DONTWAIT参数,效果等同于设置O_NONBLOCK选项后调用read(),但是效果仅限于本次调用。
注:send()函数用法类似。