终于开始学习epoll了。虽然还有很多不懂的地方,但是从理论到实践,相信自己写出一个具体的框架之后,一切都会清楚很多。1、首先需要一个内存池。目的是:减少频繁分配和释放,提高性能,避免内存碎片问题;长度;基于SLAB算法实现内存池是个好主意:分配多个不同大小的块,请求时返回大于请求长度的最小块。对于容器来说,处理固定块的分配和回收是相当容易完成的。当然记住需要设计成线程安全的,自旋锁更好,最好使用读写自旋锁。·分配内容的增长管理是一个问题。比如第一次需要1KB的空间,随着数据的不断写入,第二次需要4KB的空间。空间的扩容容易实现,但扩容必然涉及到数据拷贝。甚至,扩容需求巨大,上百兆的数据,难以处理。我暂时没有更好的主意。和STL一样,指数增长的发行策略是必然的。虽然复制数据不可避免,但至少重新分配的机会越来越小。上面说了,如果需要上百兆的数据扩充,使用内存映射文件来管理是一个很好的方式:映射文件后,虽然占用了大量的虚拟内存,但只有在需要时才使用物理内存写入会被分配,加入madvice()加入顺序写入的优化建议后,物理内存的消耗也会减少。使用string或vector来管理内存是不明智的。虽然很简单,但是在服务器软件开发中并不适合使用STL,尤其是在对稳定性和性能要求较高的情况下。2.其次要考虑的是对象池,它类似于内存池:减少对象的分配和释放。事实上,C++对象也是一个结构体。不难将构造和销毁与手动初始化和清理分开,并保留相同的缓冲区以供回收。·可以设计一个对象池只能存储一种类型的对象,对象池的实现其实就是固定内存块的池管理,非常简单。毕竟对象的数量是非常有限的。3、第三个要求是队列:如果limit的处理能力是可以预期的,最好使用固定大小的环形队列作为缓冲。一生产者一消费者是一种常见的应用场景。循环队列有其经典的“锁无关”算法。在一线程读一线程写的场景下,实现简单,性能高,不涉及资源分配和释放。.好的,真的很好!当涉及多个生产者和消费者时,tbb::concurent_queue是一个不错的选择。线程安全,并发性好,就是不知道资源的分配和释放管理的好不好。4.第四个需要的是一个映射表,或者哈希表:因为epoll是由事件触发的,一系列的过程可能分散在多个事件中,所以,必须保留中间状态,以便下一个事件发生时触发后,可以在上次处理的位置继续处理。如果想简单点,STL的hash_map还可以,但是要自己处理锁的问题,在多线程环境下用起来很麻烦。·多线程环境下的哈希表,最好的是tbb::concurent_hash_map。5、核心线程是事件线程:事件线程就是调用epoll_wait()等待事件的线程。示例代码中,一个线程完成所有工作,当需要开发高性能服务器时,事件线程应该专注于事件本身的处理,将触发事件的套接字句柄放入相应的处理中队列,以及具体的处理线程负责具体的工作。6、accept()是单线程:服务端的sockethandle(也就是调用bind()和listen()的)accept()最好在单独的线程中做,阻塞或者非阻塞都行没关系,相对于整个Server通信和用户访问动作只是一小部分。而且accept()没有放在事件线程的循环中,减少了判断。7.单个接收线程:接收线程从EPOLLIN事件发生的队列中取出socket句柄,然后在这个句柄上调用recv接收数据,直到缓冲区没有数据。接收到的数据写入以socket为key的哈希表,哈希表中有一个自增缓冲区,用于存放客户端发送过来的数据。这种处理方式适用于客户端发送的数据量很小的应用,比如HTTP服务器;如果是文件上传服务器,接收线程总会处理某个连接的海量数据,其他客户端的数据处理会产生饥饿感。所以,如果是文件上传服务器这样的场景,就不能这样设计了。8、一个发送线程:·发送线程从发送队列中获取需要发送数据的SOCKET句柄,并在这些句柄上调用send()将数据发送给客户端。SOCKET句柄保存在队列中,具体信息需要通过socket句柄在哈希表中查找,定位到具体对象。如上所述,客户端信息对象不仅有一个可变长度的接收数据缓冲区,还有一个可变长度的发送数据缓冲区。具体工作线程在发送数据时,并不直接调用send()函数,而是将数据写入发送数据缓冲区,然后将SOCKET句柄放入发送线程队列中。SOCKET句柄被放入发送线程队列的另一种情况是:事件线程中发生EPOLLOUT事件,表明TCP发送缓冲区再次有可用空间。这时,可以将SOCKET句柄放入发送线程队列中,触发send()的调用;需要注意的是,当发送线程发送大量数据时,当频繁调用send()直到TCP发送缓冲区满时,就不能再发送了。此时如果循环等待,会影响其他用户的发送工作;如果不继续发送,EPOLL的ET模式可能不再产生事件。解决这个问题的方法是在发送线程中建立一个队列,或者在用户信息对象上设置标志,在线程空闲时继续发送未发送的数据。9、需要一个定时器线程:一位使用epoll的高手说:“单靠epoll来管理描述符不泄漏几乎是不可能的,完整的解决方案很简单,就是给每个fd设置一个超时时间。如果超时时间超过超时,如果fd还没有激活,就会关闭。”·因此,定时器线程定期轮询整个哈希表,检查套接字是否在指定时间内没有被激活。不活跃的SOCKET被认为超时,然后服务器主动关闭句柄,回收资源。10、多工作线程:工作线程由接收线程触发:接收线程每次接收到数据后,将带有数据的SOCKET句柄放入一个工作队列中;工作线程从工作队列中获取SOCKET句柄,查询哈希表,定位用户信息对象,处理业务逻辑。·如果工作线程需要发送数据,它首先将数据写入用户信息对象的发送缓冲区,然后将SOCKET句柄放入发送线程队列。·对于一个任务队列,接收线程是生产者,多个工作线程是消费者;对于一个发送线程队列,多个工作线程是生产者,发送线程是消费者。这里需要注意锁的问题。如果使用tbb::concurrent_queue,会容易很多。11.仅仅使用scoket句柄作为哈希表的key是不够的:假设这样一种情况:事件线程刚刚因为EPOLLIN事件将一个SOCKET放入了接收队列,但是随后客户端异常断开,和事件线程哈希表中的此项因EPOLLERR事件而被删除。假设接收队列很长,异常的SOCKET还在队列中。接收线程在处理SOCKET时,无法通过SOCKET句柄索引到哈希表中的对象。·找不到索引的情况也很容易处理。难点在于SOCKET句柄立即被另一个客户端使用,访问线程在哈希表中为这个SOCKET创建一个对象。此时这两个句柄相同的SOCKET实际上是两个不同的客户端。在极端情况下,这是可能的。解决方案是使用socketfd+sequence作为哈希表的key,sequence是访问线程在每次accept()后累加一个整数值得到的。这样即使重用SOCKET句柄也不会有问题。12、监控需要考虑:框架中最容易出问题的是worker线程:如果worker线程的处理速度太慢,队列就会暴涨,最终导致服务器崩溃。因此,需要限制每个队列允许的最大大小,需要监控每个工作线程的处理时间。如果超过这个时间,应该使用某种方法结束工作线程。
