总结与思考

关于存储方向技术栈的学习 25/9/26

​ 这段时间一直在学习网络编程方面的知识,主要是通过一本名为《Linux高性能服务器编程》的书在学,从9/22到今天一共学了5天,学了一半学到了第九章 I/O 复用,我发现通过一本书来学习技术确实很好,因为这足够全面,里面会介绍很多我不知道但是很经典的知识,例如TCP/UDP协议、三次握手建立连接,四次握手断开连接、网络体系等,虽然说这些和后面提到的使用 socket API 建立服务器程序关系不大,但了解这些底层的知识对于以后 troubleshoot 可能会有很大的帮助。扯远了,这里主要聊一聊学习网络编程这个技术的一些心得体会。

​ 这本书出版的比较早了,是13年的书了,但直到今天上面的程序依然是能在 linux 虚拟机中运行的,书中的程序我是通过 virtual box 中的 linux 系统上的 docker 容器模拟服务器节点和客户端节点跑的,只需要用 docker 容器设置好节点的路由即可让这两个节点 ping 通。在学习这本书的过程中,从一开始的 socket API 到后面的服务器模型、网络模型,直到现在学的这个 I/O 复用讲关于使用 select、poll、epoll 实现程序同时管理多个 I/O 请求,我发现其实里面真正实用的包括代码和 API 函数只占内容的很小一部分,也就是说用到实践的话,参考的东西并不很多,可能这是技术书籍的一个通病,也可以说是缺点吧,就是为了售价更高而加入了很多概念以扩充页数,但真正用于实践的内容并不多。但仔细一看又能发现这些内容虽然说只是一个概念性的东西或者说一个说明性的东西,但是这些内容确实和后面的代码有关,不看又感觉少点什么心里不安,但假设我不看这些,只了解 API 函数,知道这些函数的作用,然后去看代码,也是能看懂的,因为程序代码本来就主要是靠这些 API 函数实现的,所以你知道这 API 函数怎么使用,那么你就能写代码,正所谓 C++ 程序等于语法+API。

​ 说了这么多,其实是想说,我应该改变这种学习技术的思维方式,不应该总想着跟紧作者的思路,每一段每一行都读懂作者要表达的意思,但事实上,现在理解的意思过一段时间就会忘,更重要的是,有时我为了理解作者这句话要表达的意思,对这句话中不明白的部分去查资料,花费了大量的时间,结果到了程序代码部分,由于脑袋里装了太多太多的知识,反而难以想起这 API 函数的使用方法,导致看代码时要一直翻到前面讲 API 的部分,以至于花了大量的时间,结果实践的部分花的时间太少反而没学会怎么用。堪称雨露均沾适得其反,总的来说,就是没有抓住书中重点,或者说没有自己的目标,不知道自己看这段内容的时候是为了什么,只知道看懂每一句话。

​ 事实上,”懂”其实是一个很主观的事情,只是对句话的一个认可,一个心理上的接受的感觉,如果是这句话中出现了没见过的名词,那么看懂这句话还有点意义,至少建立了对这个名词的心智模型,知道了不知道的事情。无论怎么说,每句话都读懂是很费时间的,有时你读懂它了之后,下一分钟就忘记了,但你为了读懂这句话花了10分钟,这就没什么必要了,其实我认为,真正的懂,不是在于感觉,而是在于使用,前者能称得上是理解,后者应该要说掌握。

​ 掌握才是最重要的,这提升了人的主观能动性能力,讲了这么多,写的也有点累了,逻辑走到这里其实可以盖棺定论了,对于今天讨论的”关于技术的学习”,其实我是在探讨,如何快速的学习技术知识,以便于能迅速上手进行开发。为了学习网络编程,能编写高性能服务器程序,我选择了一本书来看,但是我看的实在太慢,5天只看了一半,每天从早上8点看到下午5点半,只看了180来页,看到现在也是勉强能写点网络小程序,让两个能 ping 通的节点能够发送数据和接受数据了,但这个进度还是太缓慢了,任何知识离不开反复的学习,我想早点结束这本书的学习,以便于开展笔记总结工作,以及系统的复习一遍从而真正掌握初步的网络编程能力,于是今天进行了一波反思与总结,探讨这本书学习过程中学习方法的缺陷。

​ 其实就是要学会抓重点,对于书中可以掌握的内容,要重点集中时间去学习,什么是可以掌握的内容?就是说可以用于实践,需要动手操作的内容,毕竟这本书是一本技术相关的书籍,它的价值就是教会你实践网络编程,而至于书中只能理解不能指导实践的内容,其实没必要太专注的去看,过一遍就好。这是一种新的学习方式了,同样这也是一种可以掌握的内容,学会之后必然学习的很快。但先要学会区分什么是掌握性内容,什么是理解性内容,以及克服自己对每段话都必须理解必须接受的一个心理需要,我想这一点应该是最难的。

​ 最后谈一谈关于 API 函数的学习吧,以 epoll_wait 这个函数为例,它的函数原型如下:

1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

​ 首先要知道这个函数的作用,这个函数用来将 epfd 这一内核事件表中的就绪事件取出来,存放到 events 中,从而知道哪些 fd 可以操作了。先知道这个 API 的一个作用,或者说知道这个 API 的上下文。然后是关于这个函数的参数说明:

  • epfdepoll_createepoll_create1 返回的 epoll 实例文件描述符
  • events:用户态传入的 struct epoll_event 数组,用于存放内核返回的就绪事件。
  • maxeventsevents 数组的大小,必须大于 0。
  • timeout
    • > 0:等待的毫秒数,超时返回。
    • = 0:立即返回,非阻塞。
    • = -1:无限等待,直到有事件发生。

以及返回值:

  • > 0:返回就绪的文件描述符数量。
  • = 0:超时。
  • -1:出错(例如信号中断 EINTR)。

最后写一下使用流程,基本上的框架就是:

  1. 写函数原型,例如:

    1
    2
    3
    4
    5
    6
    #include <sys/epoll.h>

    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
    /*
    然后下面写参数说明,格式如上
    */
  2. 写基本使用流程,例如:

epoll_wait 一般配合 epoll_ctl 使用,流程是:

  1. 创建 epoll 实例

    1
    int epfd = epoll_create1(0);
  2. 添加要监听的 fd(socket、文件等)

    1
    2
    3
    4
    struct epoll_event ev;
    ev.events = EPOLLIN; // 监听可读事件
    ev.data.fd = listenfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
  3. 等待事件发生

    1
    2
    struct epoll_event events[1024];
    int nfds = epoll_wait(epfd, events, 1024, -1);
  4. 处理就绪事件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    for (int i = 0; i < nfds; i++) {
    int fd = events[i].data.fd;
    if (events[i].events & EPOLLIN) {
    // fd 可读
    }
    if (events[i].events & EPOLLOUT) {
    // fd 可写
    }
    }
  5. 最好给出一个示例代码,例如:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    #include <sys/epoll.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <unistd.h>
    #include <stdio.h>
    #include <string.h>

    #define MAX_EVENTS 10
    #define PORT 8080

    int main() {
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    bind(listenfd, (struct sockaddr *)&addr, sizeof(addr));
    listen(listenfd, 5);

    int epfd = epoll_create1(0);
    struct epoll_event ev, events[MAX_EVENTS];
    ev.events = EPOLLIN;
    ev.data.fd = listenfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; i++) {
    if (events[i].data.fd == listenfd) {
    int connfd = accept(listenfd, NULL, NULL);
    ev.events = EPOLLIN;
    ev.data.fd = connfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
    } else if (events[i].events & EPOLLIN) {
    char buf[1024];
    int n = read(events[i].data.fd, buf, sizeof(buf));
    if (n <= 0) {
    close(events[i].data.fd);
    } else {
    write(events[i].data.fd, buf, n); // echo
    }
    }
    }
    }
    close(epfd);
    return 0;
    }
  6. 最好还有注意事项,例如:

    1. events 是由内核写入的,调用前不用初始化

    2. maxevents 要合理设置,通常和 events 数组大小相同。

    3. 如果 timeout = -1,进程会一直阻塞直到有事件发生。

    4. 处理事件时要小心:

      LT 模式:没读完/写完的数据,下次还会触发。

      ET 模式:必须一次性读/写到 EAGAIN,否则数据可能丢失。