《UNIX网络编程 卷1》 笔记:非阻塞式I/O

时间:2022-04-25 22:49:51

本节讨论非阻塞式I/O操作。默认的I/O操作如readwrite等都是以阻塞的方式工作,比如read函数,如果没有数据可读取则进程一直阻塞直到有数据可读取。这种方式会带来某些问题。回到我们在I/O复用 select函数这一节实现的客户与服务器交互的str_cli函数,摘取其中处理标准输入的一段代码如下:

if (FD_ISSET(fileno(fp), &rset)) { /*标准输入可读*/  
if ((n = Read(fileno(fp), buf, MAXLINE)) == 0) { /*标准输入遇到EOF*/
stdineof = 1;
Shutdown(sockfd, SHUT_WR); /*关闭连接的发送端*/
FD_CLR(fileno(fp), &rset);
continue;
}
Writen(sockfd, buf, n);
}
进程从标准输入读取数据,然后调用Writen将数据通过套接字发送给服务器。问题是Writen函数(内部调用write)有可能会阻塞(比如发送缓冲区没有可用空间),此时如果套接字有来自服务器回射的数据,我们就无法及时处理。

为了解决这个问题,可以使用非阻塞式I/O操作,将标准输入、标准输出、套接字描述符都设置为非阻塞方式。这样I/O操作就不会阻塞,我们可以及时处理下一个事件。

为了实现非阻塞式I/O操作,我们需要维护两个缓冲区,to容纳从标准输入发往服务器的数据,fr容纳从服务器到标准输出的数据。以to缓冲区为,如下图所示:

《UNIX网络编程 卷1》 笔记:非阻塞式I/O

从标准输入成功读取数据放到缓冲区时,toiptr指针前移。此时进程是生产者。

成功往服务器发送数据时tooptr指针前移。此时进程是消费者。

(toiptr -tooptr)为要发往服务器的数据,一旦没有要往服务器发送的数据,toiptr指针和tooptr指针指向缓冲区起始位置。

以to缓冲区为例,生产者的代码如下:

if (FD_ISSET(STDIN_FILENO, &rset)) { /*从标准输入读取数据放到to缓冲区*/
if ((n = read(STDIN_FILENO, toiptr, &to[MAXLINE] - toiptr)) < 0) {
if (errno != EWOULDBLOCK)
err_sys("read error on stdin");
} else if (n == 0) {
stdineof = 1;
if (tooptr == toiptr)
Shutdown(sockfd, SHUT_WR);
} else {
toiptr += n;
FD_SET(sockfd, &wset);
}
}
消费者的代码如下:

if (FD_ISSET(sockfd, &wset) && ((n = toiptr - tooptr) > 0)) { /*从to缓冲区取出数据发送到服务器*/
if ((nwritten = write(sockfd, tooptr, n)) < 0) {
if (errno != EWOULDBLOCK)
err_sys("write error to socket");
} else {
tooptr += nwritten;
if (tooptr == toiptr) {
toiptr = tooptr = to;
if (stdineof)
Shutdown(sockfd, SHUT_WR);
}
}
}
读者在书中可以看到,整个str_cli函数的代码实现有90行左右,算是比较复杂的了。与多进程的版本比较,它的性能也只好那么一点,所以还是推荐使用多进程(或多线程)的方式。但是此例这种生产者和消费者的编程模型还是很重要的!

使用多进程的方式,我们还是使用阻塞式I/O,父进程处理标准输入到套接字的数据,子进程处理套接字到标准输出的数据,这样即使其中一个进程阻塞了也不会影响到另一个进程。代码如下:

void str_cli(FILE *fp, int sockfd)
{
pid_t pid;
char sendline[MAXLINE], recvline[MAXLINE];

/*子进程 socket ---> stdout*/
if ((pid = Fork()) == 0) {
while (Readline(sockfd, recvline, MAXLINE) > 0)
Fputs(recvline, stdout);

/*子进程向父进程发送一个SIGTERM信号,以防止父进程继续运行*/
kill(getppid(), SIGTERM);
exit(0);
}
/*父进程stdin ---> socket*/
while (Fgets(sendline, MAXLINE, fp) != NULL)
Writen(sockfd, sendline, strlen(sendline));
Shutdown(sockfd, SHUT_WR);
pause();

return;
}
接下来我们来实现非阻塞connect函数。

发起一个非阻塞connect,有三个用途
1. 我们可以把等待三路握手完成的时间叠加在其他处理上。
2. 我们可以使用这个技术同时建立多个连接。
3. 我们可以使用select指定一个时间限制,缩短connect的超时。

注意的一点是select要监听建立连接的套接字描述符的读事件和写事件,因为连接成功时,描述符是可写的。连接失败时,描述符是可读和可写的。具体实现代码如下:

int connect_nonb(int sockfd, const SA *saptr, socklen_t salen, int nsec)
{
int flags, n, error;
socklen_t len;
fd_set rset, wset;
struct timeval tval;

/*将套接字描述符设置为非阻塞*/
flags = Fcntl(sockfd, F_GETFL, 0);
Fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

error = 0;
if ((n = connect(sockfd, saptr, salen)) < 0)
if (errno != EINPROGRESS) /*返回EINPROGRESS表示连接正在进行*/
return -1;
if (n == 0) /*连接完成*/
goto done;

/*当连接正在进行时,可以做其他事*/
FD_ZERO(&rset);
FD_SET(sockfd, &rset);
wset = rset;
tval.tv_sec = nsec;
tval.tv_usec = 0;

/*监听套接字描述符*/
if ((n = Select(sockfd + 1, &rset, &wset, NULL,
nsec ? &tval : NULL)) == 0) { /*返回0 则连接超时*/
close(sockfd);
errno = ETIMEDOUT;
return -1;
}
/*连接成功建立时, 套接字描述符变得可写;
当连接建立遇到错误时, 描述符变为即可读又可写*/
if (FD_ISSET(sockfd, &rset) || FD_ISSET(sockfd, &wset)) {
len = sizeof(error);
/*检查是否发生错误,错误值放在error*/
if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) < 0)
return -1;
} else
err_quit("select error:socket not set");

done:
/*恢复描述符标志*/
Fcntl(sockfd, F_SETFL, flags);

if (error) {
close(sockfd);
errno = error;
return -1;
}

return 0;
}