0%
这是一片思考的空间 -- arthinking
Spring 重构&代码整洁之道 软件设计 JVM 并发编程 数据结构与算法
Java技术栈 - 涉及Java技术体系

三分钟短文快速了解信号驱动式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()函数演示其工作原理如下图所示:

image-20201101160607451

系统注册了ISGIO信号处理函数,并且启动了信号驱动式IO后,就可以继续执行程序了,等到数据报准备好之后,内核会发送一个SIGIO信号给应用进程,然后应用进程在信号处理函数中调用recvfrom读取数据报。

这种模型在内核等待数据报达到期间进程不会被阻塞,可以继续执行。

2、套接字中使用信号驱动式I/O

2.1、使用方法

针对套接字使用信号驱动式I/O(SIGIO),要求进程执行以下3个步骤:

  • 建立SIGIO信号的信号处理函数
  • 设置套接字的属主,通常使用fcntlF_SETOWN命令设置;
  • 开启套接字的信号驱动式I/O,通常使用fcntlF_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信号,导致应用进程对该信号一头雾水,没法确定套接字具体发生了什么事情。

image-20201031181855665

不过可以对TCP监听套接字可以使用SIGIO,因为对于监听套接字,产生SIGIO信号的唯一条件是某个新连接完成了。这样就可以在SIGIO信号处理函数中获取新连接了。

image-20201031183251336

2.3、在UDP中的使用

在UDP套接字中,只有以下两个条件会产生SIGIO信号:

  • 数据报到达套接字;
  • 套接字上发生异步错误。

所以,针对UDP套接字产生的SIGIO信号,我们只要调用recvfrom读入到达的数据,或者获取发生的异步错误就可以了。

image-20201031182748625

2.4、使用案例

下面我们举一个NTP服务器的例子,用于产生一个精确的数据报到达服务器的时间戳,返回给用户计算到服务器的RTT。

在一般的UDP服务器中,都是服务端不断循环读取数据,写数据。

但是为了尽可能准确的记录数据报到达服务器的时间戳,避免受到服务器处理其他工作的影响,在数据一到达服务器的时刻,就需要把时间记录下来,为此,我们需要做一些特殊的设计:数据到达之后,让内核触发一个SIGIO信号:

  • 应用进程接收到SIGIO信号之后,执行信号处理函数,通过非阻塞的socket recvfrom方法尝试读取已经准备好的数据到用户进程缓冲区,然后放入到数据报队列中;
  • 在应用进程主循环一直尝试从数据报队列中读取数据,并通过sendto方法,把数据包发送给客户端。

完整流程如下图所示:

在应用进程开启了信号驱动式IO之后:

image-20201101160117531

注意:POSIX信号通常不排队,这也就意味着,假如SIGIO处理函数正在执行,又有两个新的SIGIO信号传过来,会被当前处理函数阻塞掉,当当前SIGIO函数处理完,最后只有新的一个SIGIO会触发继续执行SIGIO函数,丢失了一个信号。

下面是该案例的代码[1],添加上了详细的注释说明:

image-20201219122235244

image-20201219122304921

image-20201219122323871

image-20201219122345028

image-20201219122402200

由于本程序中存在共享变量nqueue,信号处理函数中处理已准备好数据主循环发送消息队列中的数据都会用到这个共享变量,所以我们需要通过阻塞SIGIO以及解除阻塞SIGIO来协调信号处理函数和主循环发送消息数据,避免变量产生竞争状态。关键代码:

1
2
3
4
5
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复用,是当前众多高性能网络框架大放异彩的有力技术支撑。

References


  1. UNIX网络编程 卷1:套接字联网API(第三版). 人民邮电出版社. P527 ↩︎

欢迎关注我的其它发布渠道