一个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;
              }
              else
                  return HANDLE_WRITE_FAILURE;
          }
          else if (0 == count)
              return HANDLE_WRITE_FAILURE;
          nbyte -= count;
      }
      

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

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

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

  • 最后一点,就是比较难意识到的,一开始我也忽略了这个严重的问题,那就是网络拥塞的情况

    • 比如:当对方的请求过大,而你和对端约定的MTU比这个请求数据要小的时候,会发生分片,而一旦某个分片先到达,而其他分片由于某些原因没有同时到达
    • 那么也许read返回-1errno == EAGAIN的时候,数据其实并没有读取完毕。
    • 这种情况下,我们需要靠自己来判断数据是否完整
      • GET,HEAD之类的请求方法时,以收到的数据中是否含有空行\r\n为基准
      • POST则更麻烦些,需要解析出Content-Length属性,用以确定其报文体的尾部。

缺陷

  • 那就是对客户端信息的包装不够
  • 具体体现在,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.4or2.6)。

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

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

末尾

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

转载请注明原处 : )

http://www.wushxin.top/2016/03/26/%E4%B8%80%E4%B8%AAHTTP%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%9A%84C%E4%B9%8B%E8%B7%AF-%E4%B8%8B.html