三分钟短文快速了解信号驱动式IO,似乎没那么完美原创
本文为《高性能网络编程游记》的第二篇 “信号驱动式IO,似乎没那么完美”。
前面的图解Socket核心内幕以及五大IO模型一文中,我们介绍了五大IO模型。在本文中,我们重点来介绍一下目前操作系统中实现信号驱动式IO,了解其使用场景和局限性,通过一个比较完整的案例来演示其用法。
1、信号驱动式I/O模型介绍
1.1、基本介绍
所谓信号驱动式I/O(signal-driven I/O),就是预先告知内核,当某个描述符准备发生某件事情的时候,让内核发送一个信号通知应用进程。
主要的实现:
-
Berkeley的实现使用
SIGIO
信号支持套接字和终端设备上的信号驱动式I/O; -
SVR4使用SIGPOLL信号支持流设备上的信号驱动。
SIGPOLL等价于SIGIO。
通过UDP的recvfrom()
函数演示其工作原理如下图所示:
系统注册了ISGIO信号处理函数,并且启动了信号驱动式IO后,就可以继续执行程序了,等到数据报准备好之后,内核会发送一个SIGIO信号给应用进程,然后应用进程在信号处理函数中调用recvfrom读取数据报。
这种模型在内核等待数据报达到期间进程不会被阻塞,可以继续执行。
2、套接字中使用信号驱动式I/O
2.1、使用方法
针对套接字使用信号驱动式I/O(SIGIO),要求进程执行以下3个步骤:
-
建立SIGIO信号的
信号处理函数
; -
设置
套接字的属主
,通常使用fcntl
的F_SETOWN
命令设置; -
开启
套接字的信号驱动式I/O
,通常使用fcntl
的F_SETFL
命令打开O_ASYNC
标志。
O_ASYNC标志是POSIX规范中比较新的内容,支持该标志的系统不多,后面我们会改动ioctl的FIOASYNC请求代为开启信号驱动式I/O。
光从FIOASYNC名字看,好像是异步IO,其实由于历史原因,这个命名不太恰当,改用O_SIGIO命名会更贴切点。
我们很容易把一个套接字设置为信号驱动式I/O,但是确定哪些条件会导致内核产生递交给套接字的属主的SIGIO信号比较难,这取决于支撑的协议。
2.2、在TCP中的使用
信号驱动式I/O对于TCP套接字产生的作用不大。因为该信号在TCP套接字中产生的过于频繁。
以下条件均会导致对一个TCP套接字产生SIGIO信号:
-
监听套接字上某个连接请求已经完成;
-
某个断连请求已经发起;
-
某个断连请求已经完成;
-
某个连接之半已经关闭;
-
数据到达套接字;
-
数据已经从套接字发送走;
-
发生某个异步错误。
这么多条件都会触发SIGIO信号,导致应用进程对该信号一头雾水,没法确定套接字具体发生了什么事情。
不过可以对TCP监听套接字可以使用SIGIO,因为对于监听套接字,产生SIGIO信号的唯一条件是某个新连接完成了。这样就可以在SIGIO信号处理函数中获取新连接了。
2.3、在UDP中的使用
在UDP套接字中,只有以下两个条件会产生SIGIO信号:
-
数据报到达套接字;
-
套接字上发生异步错误。
所以,针对UDP套接字产生的SIGIO信号,我们只要调用recvfrom读入到达的数据,或者获取发生的异步错误就可以了。
2.4、使用案例
下面我们举一个NTP服务器的例子,用于产生一个精确的数据报到达服务器的时间戳,返回给用户计算到服务器的RTT。
在一般的UDP服务器中,都是服务端不断循环读取数据,写数据。
但是为了尽可能准确的记录数据报到达服务器的时间戳,避免受到服务器处理其他工作的影响,在数据一到达服务器的时刻,就需要把时间记录下来,为此,我们需要做一些特殊的设计:数据到达之后,让内核触发一个SIGIO信号:
-
应用进程接收到SIGIO信号之后,执行信号处理函数,通过非阻塞的socket recvfrom方法尝试读取已经准备好的数据到用户进程缓冲区,然后放入到数据报队列中;
-
在应用进程主循环一直尝试从数据报队列中读取数据,并通过sendto方法,把数据包发送给客户端。
完整流程如下图所示:
在应用进程开启了信号驱动式IO之后:
注意:POSIX信号通常不排队,这也就意味着,假如SIGIO处理函数正在执行,又有两个新的SIGIO信号传过来,会被当前处理函数阻塞掉,当当前SIGIO函数处理完,最后只有新的一个SIGIO会触发继续执行SIGIO函数,丢失了一个信号。
下面是该案例的代码[1],添加上了详细的注释说明:
由于本程序中存在共享变量nqueue
,信号处理函数中处理已准备好数据
和主循环发送消息队列中的数据
都会用到这个共享变量,所以我们需要通过阻塞SIGIO以及解除阻塞SIGIO来协调信号处理函数和主循环发送消息数据,避免变量产生竞争状态。关键代码:
Sigprocmask(SIG_BLOCK, &newmask, &oldmask); // 阻塞SIGIO
for ( ; ; ) {
while (nqueue == 0)
sigsuspend(&zeromask); /* wait for datagram to process */
...
如果这里没有阻塞SIGIO,可能的执行流程是这样的:
先判断到 nqueue等于0之后,内核立即发送了SIGIO函数,然后在执行sigsuspend
,然后阻塞等待新的数据报达到,进程接着处理刚刚接收的SIGIO信号,执行函数,把数据放入队列中,然后nqueue+1,但是如果没有新的SIGIO信号到达,那么进程永远不会从sigsuspend处醒过来,队列中已接受的数据永远也不会被服务器循环处理。
类似于多进程或者多线程程序的同步处理,操作需要保证原子性,就必须通过一定的手段来实现,这里主要是通过手动执行
sigprocmask
阻塞SIGIO来实现避免共享变量的竞争关系的。更多关于如何处理同步的技能,可以参考:一文带你彻底理解同步和锁的本质(干货)。
信号机制在操作系统中似乎有点被过渡设计,当有大量IO操作的时候,可能会因为信号队列溢出导致没法进行通知。
而我们在下文深入介绍的IO复用,是当前众多高性能网络框架大放异彩的有力技术支撑。
今天关于信号驱动式IO的内容就介绍到这里了,关于目前主力的IO复用技术的原理,使用案例,底层实现细节,以及为何epoll性能比较好?我们将在下讲进行详细的说明。
为了把相关系列文章收集起来,方便后续查阅,这里我创建了一个Github仓库,把发布的文章按照分类收集起来了,感兴趣的朋友可以Star跟进:
https://github.com/arthinking/java-tech-stack
References
[1]: UNIX网络编程 卷1:套接字联网API(第三版). 人民邮电出版社. P527