Linux系统编程 / triggerhappy 源码分析(3.select 的应用)

哈喽,我是老吴,继续记录我的学习心得。

一、进步的滞后性

我们期望进步是线性:
每一个人付出一些努力后,都希望它有立竿见影的效果。

现实是:
做出努力后,结果的显现往往滞后。

只有在几个月或几年后,我们才意识到以前学习/工作的真正价值。

“失望谷地” 的出现:
人们在投入数周或数月的辛勤工作后,却没有任何看得见的效果,于是进入深感沮丧的时期。

如何改变:
1> 改变意识。功夫并没有白费,它只是蓄积起来了。直到很久以后,以前努力的全部价值才会显露出来。

2> 寻找一些可以帮助自己对抗滞后进步所带来的低落情绪的技巧。例如培养习惯的四大定律,具体的如即时奖励、习惯追踪等。


二、triggerhappy 源码分析 / 3.select() 的应用

正文目录:

1. thd.c / process_events() 源码分析
2. I/O 多路复用 ( Multiplexing ) 是为了解决什么问题
3. I/O 多路复用设计思路
4. select() 的用法
5. 和 poll/epoll 对比(补充知识,非重点)
6. triggerhappy:thd.c / process_devices() 源码分析
7. 相关参考

写作目的:

  • 通过阅读 triggerhappy 的源码,学习 I/O 多路复用的方法之一 select() 的使用方法。

测试环境:

  • Ubuntu 16.04

  • Gcc 5.4.0

1. thd.c / process_events() 源码分析

1.1 process_events() 的作用

1) 作用:

  • 监控 input 设备;

  • 读取 input 事件,并执行相应的 action;

  • 检查 socket 是否有接受到命令。

2) 调用流程:

thd.c
    main()
        start_readers()
            process_events()

1.2 process_events() 的内容:4 个步骤

1) 建立主循环:

while ( count_devices() > 0 || cmd_fd != -1 ) {
    [...]
}

2) 在主循环内,使用 select 监测多个 input 设备文件 (重点):

while(...) {
    [...]       // some init for select
    retval = select(max_fd+1, &rfds, NULL, NULL, &tv);
}

本文的重点就是了解 select 相关的知识点。

3) select 返回后,调用 process_devices() 读取数据 (重点):

while(...) {
    [...]       // some init for select
    retval = select(max_fd+1, &rfds, NULL, NULL, &tv);
    if (retval) {
        process_devices();
    }
}

process_devices() 是 triggerhappy 的重点函数。

先大致了解一下它的作用:
对于每一个有待读数据的 input 设备文件,都调用 read() 函数读取数据,然后解析数据,根据解析结果去执行相应的 action,action 在 example.conf 中定义:

$ cat /etc/triggerhappy/triggers.d/example.conf
KEY_VOLUMEUP    1        /usr/bin/amixer set Master 5%+
...

/usr/bin/amixer set Master 5%+ 就是一个 action。

暂时不用太深入,后续会专门写一篇文章来详细分析 process_devices()。

4) 是否有接收到用户命令 (th-cmd)?

while(...) {
    [...]
    if (retval) {
        process_devices();
        if ( cmd_fd != -1 && FD_ISSET( cmd_fd, &rfds ) ) {
            struct command *cmd = read_command( cmd_fd );
            obey_command( cmd );
            free(cmd);
        }
    }
}

cmd_fd 是一个 socket:

main()
    start_readers()
        cmd_fd = bind_cmdsocket(cmd_file);
            cmd_fd = socket(AF_UNIX, SOCK_DGRAM, 0);

triggerhappy 支持使用 socket 通信以动态增加和删除输入设备,例如动态地添加要监测的输入设备 /的 /dev/input/event0:

$ thd --socket /var/run/triggerhappy.socket
$ th-cmd --socket /var/run/triggerhappy.socket --add /dev/input/event0

read_command() 和 obey_command() 负责读取和解析用户通过 socket 发送过来的命令,后面找时间写一些关于 domain socket 的文章。

接下来,我们先学习一下 select 的机制、使用方法和注意事项

注意:本文的写作目的不是为了描述 select() 完整的使用方法和内部实现,而是在有限的篇幅内尽量整理一下 I/O 多路复用相关的知识点,即仅仅是引子,更完整的内容仍需要小伙伴自行阅读相关书籍。

2. I/O 多路复用 ( Multiplexing ) 是为了解决什么问题: 多文件阻塞

应用通常需要在多个文件描述符上阻塞,例如在键盘输入、进程间通信以及其他文件之间协调 I/O。

很多情况下,一个文件描述符依赖另一个文件描述符。只要是有 1 个文件描述符数据还没有准备好,比如发送了 read() 调用,但是还没有任何数据,就进程会阻塞在此 I/O 操作上,此时无法对其他的文件描述符提供服务。这导致应用效率变低,影响用户体验。

尤其是对于网络应用而言,可能会同时打开多个 socket,如果应用阻塞在某个 socket上,将引发很多问题。

设计 I/O 多路复用就是为了解决上述问题:
支持应用同时在多个文件描述符 (普通文件、管道、套接字...) 上阻塞,并在其中某个文件描述符可以读写时收到通知。

3. I/O 多路复用设计思路

1> 无 I/O 就绪:在有可用的文件描述符之前一直处于睡眠状态;

2> 任意文件描述符 I/O 就绪:唤醒应用,应用检查是哪个文件描述符可用了;

3> 执行 I/O 操作:应用处理所有 I/O 就绪的文件描述符,此时没有阻塞;

4> 返回第 1> 步:重新开始;

Linux 提供了三种 I/O 多路复用方案:
-select()、poll() 和 epoll()。

本文先将学习重点放在 select() 上,同时也稍微了解一下 poll() 和 epoll(),明确三者的优缺点。

4. select() 的用法

4.1 函数概述

$ man 2 select

/* According to POSIX.1-2001, POSIX.1-2008 */
#include <sys/select.h>

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • select() 会监测 3 个 文件描述符集,直到有 1 个或多个文件描述符集成为就绪态

  • 在给定的文件描述符 I/O 就绪之前并且还没有超出指定的时间限制,select() 会阻塞。

4.2 参数说明

1) 参数 fd_set * readfds、writefds、exceptfds:

  • select() 监测 3 种 I/O 事件(读、写、异常),每 1 种事件对应 1 个文件描述符集。

  • 每个描述符集存储在一个 fd_set 数据类型中,可以认为它是一个很大的位数组 (array of bits):


  • select() 中间 3 个文件描述符集参数 readfds、writefds 和 exceptfds 分别对应了我们关心的可读、可写或处于异常条件的文件描述符集。

2) 操作文件描述符集的几个宏:

  • FD_ZERO:从指定集合中删除所有的文件描述符;

  • FD_SET:向指定集中添加一个文件描述符,而FD_CLR则从指定集中删除一个文件描述符;

  • FD_ISSET:检查一个文件描述符是否在给定集合中;

  • 由于文件描述符集是静态建立的,所以文件描述符数存在上限值,而且存在最大文件描述符值,这两个值都是由 FD_SETSIZE 设置。在 Linux 中,该值是1024;

3) 参数 int nfds:最大文件描述符 + 1

  • 参数 nfds 必须设为比 3 个文件描述符集合中所包含的最大文件描述符 + 1 的值。

  • 传递该参数是为了让内核不用去检查大于这个值的文件描述符是否属于上述 3 个文件描述符集合,这也从侧面说明了 select() 在内核里实现是会遍历 0~nfds 文件描述符。

  • select() 的设计天生就没考虑到用于监测大量文件 I/O 就绪的情景。但是在 triggerhappy 里使用 select() 则是合理的,这是因为 一个系统上的 input 设备并不会太多,本文末尾还会对 select() 的优缺点进行总结。

4) 参数 struct timeval *timeout:

  • timeout == NULL:永远等待。当所指定的描述符中的之一已就绪或捕捉到一个信号则返回。如果捕捉到一个信号,则返回 -1,errno 设置为 EINTR。

  • timeout->tv_sec == 0 && timeout->tv_usec == 0:完全不等待。测试所有指定的描述符并立即返回。

  • timeout->tv_sec != 0 || timeout->tv_usec != 0:等待指定的秒数和微秒数。当指定的描述符之一已就绪,或超过指定的时间值时立即返回。如果在超时到期时还没有如何描述符准备好,则返回 0。

4.3 返回说明

1) return = -1:表示有错误发生。典型的错误码 (errno) 包括 EBADF 和 EINTR 等。

  • EBADF: 表示 readfds、writefds、exceptfds 中有非法文件描述符。

  • EINTR: 表示该调用被某个信号中断了。

2) return = 0:表示在有文件描述符成为就绪态之前 select() 已经超时。

  • 在这种情况下,每个返回的文件描述符集合将被清空。

3) return > 0:表示有 1 个或多个文件描述符已就绪。

  • 返回值表示处于就绪态的文件描述符个数。

  • 每个集合都修改成只包含相应类型的 I/O 就绪的文件描述符。

  • 每个集合都需要检查通过 FD_ISSET() 以此找出发生的 I/O 事件是什么。

  • 如果同一个文件描述符在 readfds、writefds 和 exceptfds 中同时被指定,且它对于多个 I/O 事件都处于就绪态的话,那么就会被统计多次。。例如同一描述符已准备好读和写,那么在返回值中会对其计 2 次

4.4 注意事项

  • 每次调用 select() 之前,都要对所有参数进行初始化;

  • 在 Linux 上,如果 select() 被信号中断的话,struct timeval 会被修改以表示剩余的超时时间;

  • exceptfds 常常被误解为在文件描述符上出现了异常情况,这是错误的。它其实与伪终端和流式套接字相关;

  • 如果在一个描述符上碰到了文件尾端,select() 会认为该描述符是可读的。此时调用 read(),会返回 0,这是 UNIX 系统指示到达文件尾端的方法。很多人错误地认为,当到达文件尾端时, select() 会指示一个异常条件。

  • 如果想更详细地了解 I/O 多路复用的使用方法,可以学习一下开源软件 libevent。

4.5 最简单的 demo 程序

1) 初始化select() 的参数:

#define TIMEOUT 5
#define BUF_LEN 1024

int main (void)
{
    struct timeval tv;
    fd_set readfds;
    int ret;

    /* Wait on stdin for input. */
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);
    
    /* Wait up to five seconds. */
    tv.tv_sec = TIMEOUT;
    tv.tv_usec = 0;

    [...]
}

2) select () 监测是否有 I/O 就绪:

int main (void)
{
    [...]   // 1) init select param

    /* All right, now block! */
    ret = select (STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);
    if (ret == -1) {
        perror ("select");
        return 1;
    } else if (!ret) {
        printf ("%d seconds elapsed.\n", TIMEOUT);
        return 0;
    }
    [...]
}

3) 进行 I/O 操作:

int main (void)
{
    [...]   // 1) init select param
    [...]   // 2) do select()

    // 3) do I/O
    if (FD_ISSET(STDIN_FILENO, &readfds)) {
        char buf[BUF_LEN+1];
        int len;
        
        len = read (STDIN_FILENO, buf, BUF_LEN);
        if (len == -1) {
            perror ("read");
            return 1;
        }
        if (len) {
            buf[len] = '\0';
            printf ("read: %s\n", buf);
        }
    }
    
    return 0;
}

4) 运行效果:

$ gcc simple_select.c -o simple_select
$ ./simple_select 
a
read: a

启动程序时,程序会阻塞,这时从键盘敲入 'a',程序会被唤醒。

5. 和 poll / epoll 进行简单对比(补充知识,非重点)

5.1 对比 poll()

$ man 2 poll

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int   fd;         /* file descriptor */
    short events;     /* requested events */
    short revents;    /* returned events */
};
  • poll()系统调用是 System V 的 I/O 多路复用解决方案。它解决了一些select()的不足,不过出于习惯或可移植性的考虑,select() 还是被频繁使用

  • poll 的监测对象是 struct pollfd 数组,它可以精准的指定要监测的文件集合。而 select() 的文件描述符集合是静态的,当文件集合是稀疏的时,例如要监测文件描述符 0 和 1000 时,select() 的效率比 poll() 低很多

  • select() 返回时会重新创建文件描述符集,因此每次调用都必须重新初始化。poll() 系统调用会把输入 (events 成员) 和输出 (revents 成员) 分离开,无需重新初始化数组就可以重新使用

5.2 对比 epoll

  • epoll 是 Linux 特有的 I/O 多路复用解决方案

  • epoll 的实现比 poll() 和 select() 要复杂得多,epoll 解决了前两个都存在的基本性能问题,并增加了一些新的特性。

  • poll() 和 select() 每次调用时,内核必须遍历所有被监视的文件描述符。当文件描述符列表变得很大时,例如包含几百个甚至几千个文件描述符时,每次调用都要遍历列表就变成规模上的瓶颈。而 epoll 监测的对象是事件,无需遍历文件列表,所以在性能上有很大的提升

  • select、poll、epoll 的后端都是 struct file_operations 里的 unsigned int (*poll) (struct file *, struct poll_table_struct *);。以后有机会的话,会继续跟进它们三者在内核里的实现。

6. 继续分析 triggerhappy:thd.c / process_devices():读取 input 数据

static void process_devices(void) {
    for_each_device( &check_device );
}

devices.c / for_each_device() 会遍历所有 input 设备(内含 input 文件描述符),对每一个设备都调用 check_device() 函数:

static void check_device( device *d ) {
    int fd = d->fd;
    if (FD_ISSET( fd, &rfds )) {
        if (read_event( d )) {
            /* read error? Remove the device! */
            remove_device( d->devname );
        }
    }
}

thd.c / read_event() 执行input 数据的 read 操作:

static int read_event( device *dev ) {
    [...]           // some init

    struct input_event ev;
 int n = read( fd, &ev, sizeof(ev) );

    [...]           // parse and do action
    run_triggers( ev.type, ev.code, ev.value, *keystate, dev );
}

1 个input_event 事件相当于产生了一个 trigger,接下来的重点就是调用 trigger.c / run_triggers() 来触发用户自定义的 action 了。

鉴于大多数人的注意力无法在一篇文章里上集中太久,更多的内容将放在后面的文章里。建议大家可以先自行阅读相关书籍,不是自己理解到的东西是消化不了的。

7. 相关参考

  • 《UNIX 环境高级编程》,14,16,17.6 章节

  • 《Linux 系统编程》,2.10,4.2,8.7,11.7 章节

  • 《Linux/UNIX系统编程手册》,63 章节

  • Libevent


 推荐阅读:

    专辑|Linux文章汇总

    专辑|程序人生

    专辑|C语言

嵌入式Linux

微信扫描二维码,关注我的公众号

猜你喜欢

转载自blog.csdn.net/weiqifa0/article/details/108525562