神刀安全网

一个HTTP服务器的C之路

趁着早起, 接着昨天

功能选择

  • 选择使用的 epoll
  • 实际上, epoll 应该是代表单进程的极端表现,最大程度的发挥一个核的最大实力,但是对多核来说就有些无法触及,但是在此处我们可以考虑将 epoll 扩展出去。
  • epoll 的作用是监听已被注册到自身的那些文件描述符的各种事件(可读,可写等等)。我们可以考虑让 监听套接字 独享一个 epoll (连接epoll),并且在其之下(逻辑中的下,实际上没有直接连接接触)用多线程/多进程建立几个处理新连接事物的专用 epoll (事务epoll)。就是这么简单的思路。

    • 对比一下多线程还是多进程:
    • 多进程 模式是独立性较好,在忽略所谓的进程创建开销(对于本程序而言可以忽略,因为总是创建固定数量的进程,而不是在运行程序是一直创建,销毁)情况下,多进程还有一个缺点,那就是进程间通信(IPC)开销大,即便是使用 共享内存 也是如此,因为需要 打开描述符open,映射mmap,关闭描述符close ,(还隐含着解除映射munmap),这样的操作。
    • 多线程 模式是对于数据的独立性差,十分容易出错,特别是竞争条件的产生是多核编程中最为核心的问题。
  • 针对上面的问题,可以参考 分布式系统 的设计过程中,有一种叫做 一致性哈希 的设计思想,也就是不要让 事务epoll 相互竞争,而是让 连接epoll 自己将新的到的连接,分发给这些固定数量的 事务epoll 中的某一个,并且应该形成 均衡发布

    • 后期会实现,当某一个线程意外退出以后,事务会均衡发放给离自己最近的线程。

      epoll

  • 对于 epoll 而言, 连接epoll 所处理的事情十分简单,就是负责整个网络程序服务端工作中的第四个部分 accept ,只需要对监听套接字的 可读事件(EPOLLIN) 敏感就行,这样就讲现成的撰写难度降低。而对于 事务epoll 而言,事情会稍微复杂一些。

    • 事务epoll 是真正处理实际连接的,也就是对 HTTP 请求做出回应的。
    • 在这些 epoll 中,我们需要处理的就是 三种事件 : ( 可读可写错误 ),这里很多刚接触的人(包括我),都会将可读和可写放在一起处理,其实这是不怎么好的方法,试想这种情况:
      • 你接受到了一个新连接请求(连接epoll处理了),并且这个新连接被分发到了某一个 事物epoll 中,且产生了一个可读事件,并被捕捉到了,这时候你处理完可读事件之后,直接向其发送数据。
      • 此时就是一个性能点,如果此时你本机的TCP写缓冲满了怎么办?用程序语言来说就是,如果此时 write 调用返回 -1errno == EAGAIN ,由于你将读写放在一个事件里(读事件),所以你没办法在这种错误发生时有补救措施。要么你一直循环重试 write 知道其成功发送(或者对方突然关闭连接,返回 0 ),这就会导致那个线程所在的CPU核使用率居高不下,都浪费在这里了。要么你就只能关闭连接,让 peer端 去负担这个后果( 这个由服务器端失策造成的后果! )。
    • 对 可读事件 和 可写事件 进行分离,是一个比较好,且操作起来也比较简洁的方法,这样我们可以不用同时考虑两种事件带来的复杂性,即增加复杂度。
    • 具体做法就是,在 连接epoll 获得新连接时,将其用 可读事件 注册到 事务epoll 中,一旦 事务epoll 被可读事件激活,就处理这个可读事件,并将需要发送给 peer端 的数据准备好,放在每个连接自己的缓冲区内,将这个连接重新用 可写事件 注册回自身。
    • 这样即便是TCP的写缓冲满了,我们也可以选择下次 发送剩下的数据
  • epoll 有两种模式, LTET , 这两种的区别网上详细讲解的很多,不在赘述,我在这个软件中采用的是 ET 模式,且使用了 EPOLLONESHOT 选项,这个选项在我的设计方式中,实际上是没有什么必要(目前看来)

    • EPOLLONESHOT 最开始是为了防止使用 线程池技术 时候,对防止 对新连接的竞争 时的措施,也就是说,假设A线程在处理某个新连接(A连接)的某个事件(A事件)时,突然A连接的A事件又被触发了(这是可能的,例如读事件,突然又有新数据到来),那么B线程可能就接到了这个事件,也开始处理,这就产生了冲突,会导致垃圾数据的产生。
    • 而对这个连接采用 EPOLLONESHOT 的意义就在于,每次这个连接被处理了,那么就自动从这个epoll中除名,下次想用这个epoll监视这个连接,就需要重新注册(epoll_clt)。
    • 但这对我从一开始就分配好固定的 epoll 而言,这个属性似乎没有什么必要,留下它是因为它并没有造成额外的工作,而且可以让后续的想法更流畅的实现,万一有新想法了呢:)

错误处理

前提所有的 文件描述符 都是非阻塞的。

  • accept

    • 由于 accept 是在 连接epollepoll_wait 成功时,才会调用,所以我们需要对这个 accept 一直循环,直到其返回`-1

      while (is_work > 0) { /* New Connect */     sock = accept(new_client.data.fd, NULL, NULL);     if (sock > 0) {         fprintf(stderr, "There has a client(%d) Connected/n", sock);         set_nonblock(sock);         ...      } else /* sock == -1 means nothing to accept */         break; } 

      之所以需要一直循环,是因为不一定只有一个新连接接上来。

  • read

    • 如果 read 函数返回值大于 0 ,表明正确读到数据,继续循环读
    • 如果 read 函数返回值小于 0 ,(1)且 errno == EAGAIN || errno == EWOULDBLOCK 代表缓冲区无数据可读了,注册写事件,(2)你需要关闭这个连接了
    • 如果 read 函数返回值等于 0 ,表明你需要关闭这个连接了。这代表 peer 端发了一个 FIN 给你。

      while (1) {     read_number = read(fd, buf+buf_index, BUF_SIZE-buf_index);     if (0 == read_number) { /* We must close connection */         return READ_FAIL;     }     else if (-1 == read_number) { /* Nothing to read */         if (EAGAIN == errno || EWOULDBLOCK == errno) {             buf[buf_index] = '/0';             return READ_SUCCESS;         }         return READ_FAIL;     }     else { /* Read Success */             ...     } } 

EAGAIN 和 EWOULDBLOCK 值实际上是一样的

  • write

    • 如果 write 函数返回值大于 0 ,表明正确的写了数据,继续循环写
    • 如果 write 函数返回值小于 0 ,(1)且 errno == EAGAIN 代表写缓冲满了,重新注册写事件,(2)且 errno == EPIPE ,表明你需要关闭这个连接了,这代表 peerclose 这个连接。(3) 表明你需要关闭这个连接了
    • 如果 write 函数返回值等于 0 ,这种情况应该不会发生,在系统层面来说这应该是不合法的。

      while (nbyte > 0) {     buf += count;     count = write(fd, buf, 8192);     if (count < 0) {         if (EAGAIN == errno || EWOULDBLOCK == errno) {             memcpy(client->write_buf, buf, strlen(buf));             client->write_offset = nbyte;             return HANDLE_WRITE_AGAIN;         }         if (EPIPE == errno)             return HANDLE_WRITE_FAILURE;     }     else if (0 == count)         return HANDLE_WRITE_FAILURE;     nbyte -= count; } 

EPIPE 会和一个信号 SIGPIPE 一起出现,你需要(必须)处理它,至少在它发生前处理它,不然你的程序就会被中断,最简单的处理方式就是 忽略它

EINTR 这个 errno 值,在非阻塞的套接字中不需要太过关注,但是如果是阻塞型套接字编程,那就是一个十分重要的值,需要特别关注

  • 以上是三大需要 仔细小心谨慎 处理的比较核心的错误。
  • 如果还要加一点,那就是 信号处理 ,不过这个用 gdb 很容易就定位出来了,还不懂怎么用的,可以参考我上一篇文章如何简洁地使用gdb。

缺陷

  • 那就是对客户端信息的包装不够
  • 具体体现在, GET 方法实现的时候,需要传递的参数很多,应该将这些信息包含进客户端连接的结构体中,而不是临时用变量存储,传递。
  • 一个线程如果挂掉了,很可能引发雪崩似的错误。
  • 就算没有崩溃,每崩掉一个线程( 事务epoll 所在线程)整个服务器的性能将下降 20% , 如果 连接epoll 所在的线程崩溃,整个程序也就结束了。

  • 具体项目源码 : httpd3 ·

  • 欢迎指正 : )

写在最后之前

  • 其实对于使用 多线程 还是 多进程 ,又是一个话题,这个问题我考虑了许久,实际上两者各有千秋,怎么说呢?
  • 我分享一下我当时的思路,其实我选择多线程,并不是因为多线程比多进程的方案更优,而是我看多线程更顺眼而已。

可以读一读关于 Linux 环境中,线程和进程的区别和联系,其实两者十分相似(不止体现在功能上)

  • 多进程:
    1. 我只说程序模型,而不是讨论他们的运行原理。如果是选择多进程模型,那就应该尽量避免进程间的数据传递,所以多线程的那种 负载均衡 方式就不适合了,我们可以选择创建多个进程(地位平等),每个进程都有一个 epoll 实例,且都注册了同一个 监听套接字 ,这样不就也达到了同样的并发目的。
    2. 但是随之而来的问题是: 1) 惊群现象 ,在 Linux内核 4.5 之前没有系统提供的解决方案,而距离主流内核提升到 4.5 还有漫漫长路要走。至于惊群现象这里不给出赘述,网上的解释很多,简单来说就是一个新连接到来会唤醒所有进程中的 epoll_wait ,但只有一个 epoll_wait 会成功返回。 2) 负载不均衡 , 因为每次被 成功 唤醒的进程都不确定,完全是操作系统这个二愣子出的主意,所以有可能(很有可能,到最后会接近99%)会出现一个进程忙死了,有的进程闲死(专业一点叫做 饥饿现象 )。
    3. 解决方案当然是有的,而且是很好的一箭双雕(解决方案是 nginx 的),就是用锁来解决,大概的意思就是每个进程持有自旋锁( 自己实现的 ),这个自旋锁的设计很巧妙,是有时间限制的自旋锁,且时间可自行调整,通过调整这个时间的值,来达到负载均衡的效果,即本次没有得到新连接的进程,下次锁的时间就减少,这样获得新连接的概率就增大,同时也解决了惊群现象。

惊群现象在内核 3.9 的时候,被提出解决,解决的方案是 EPOLLEXCLUSIVE 这个Event,而在最近发布的 Linux内核4.5 中被正式的修复(方案就是前面这个)。 其实在这之前还有一个系统调用会导致惊群,那就是 accept ,只不过被修复了,忘了是内核多少( 2.4 or 2.6 )。

  • 多线程
    1. 在逻辑最上层有一个 epoll 实例,用于注册 监听套接字accept 新连接,并将新连接 均衡 的分给,处于逻辑下层的各个线程中的 epoll 实例。
    2. 缺点当然很明显就是,只有一个 epoll 在逻辑上层接待新连接,要是它崩溃了,那整个程序就完了。所以就健壮性而言,不如多进程的方案。而且要是任意一个线程因为某些原因死掉了,且不说程序是否能够运行的下去,就算程序能够苟活,整个服务器的性能一定会打一个折扣。相比之下,同种情况发生在多进程方案身上最多就是损失点性能,对整个服务器的运行而言,不会造成太大的波动。

所以在我的实现中,处于上层逻辑中的 epoll 实例,也被我写成了一个数组类型,只不过初始化大小为 1 ,也就是暂时只有一个,我的想法是后期可以通过配置文件中添加新选项来进行更改。

末尾

  • 这个 HTTP服务器 程序只是一个预热,我的原计划中是要写一个 爬虫程序
  • 大致是,用这个 HTTP服务器 熟悉一下我将要战斗的地方的内部运作,考虑到现在大部分使用的都是 nginx ,我也很认真的看了它的一些(头疼,战斗民族的代码,但是比德国佬的 libuv 好太多了…)实现源码。
  • 接下来会想做一个爬虫,并且最终的目标是一个分布式架构的爬虫,如果有兴趣的话可以联系我一起,我的 E-mail 在顶部栏的 关于 里面。

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » 一个HTTP服务器的C之路

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
分享按钮