C++服务器开发:从语法基础到高并发系统核心原理
1. 项目概述为什么C依然是服务器开发的基石最近在技术社区和招聘网站上C服务器开发相关的讨论和需求又热了起来。很多刚入行的朋友可能会疑惑在Go、Java、Rust等语言大行其道的今天为什么还要啃C这块“硬骨头”我干了十多年后台开发从早期的单机服务到现在的云原生微服务C始终是那个在关键时刻“压得住场子”的角色。这个项目标题“C服务器开发从基础知识到系统原理的深入探索”恰恰点出了学习这门技术的正确路径它不是简单地学语法而是要通过它打通从应用层代码到底层系统运作的任督二脉。简单来说用C写服务器你面对的不是一个黑盒框架。从内存里每一个字节的布局到网络数据包在协议栈里的旅程再到CPU如何调度你的线程这些都需要你心中有数。这种掌控感是应对高性能、高并发、低延迟场景的底气。比如金融交易系统、游戏服务器、大型互联网基础设施这些领域里C的身影依然活跃。学习它不是为了替代其他语言而是为了在需要极致性能和控制力的地方多一种选择多一份理解。无论你是希望深入理解计算机系统还是瞄准了特定领域的高薪岗位这条从C语法到操作系统、网络原理的探索之路都值得一走。2. 核心知识体系与学习路线图2.1 语言基础超越“八股文”的深度理解提到C基础很多人会直接想到“C八股文”——面试常考的那些虚函数表、智能指针、STL源码。这没错但为了做服务器开发我们的学习角度需要更务实。语法是骨架但我们要理解的是骨架如何支撑起高并发的血肉之躯。面向对象与内存模型服务器是长时间运行、服务大量请求的进程。不恰当的对象生命周期管理会导致内存泄漏而泄漏在7x24小时的服务中会被无限放大。因此理解构造函数/析构函数的调用时机、拷贝控制成员三/五法则不仅是为了写出正确的类更是为了在资源管理上不犯错。比如一个连接句柄如socket fd如果没有在析构函数里正确关闭很快就会耗尽系统资源。STL容器与算法在服务器编程中std::vector,std::map,std::unordered_map的使用无处不在。但你不能停留在“会用”的层面。你需要知道std::vector的动态扩容机制当容量不足时它会分配一块新的更大的内存通常是原大小的2倍或1.5倍将旧元素移动或拷贝过去然后释放旧内存。在关键路径上一次不经意的push_back可能触发扩容带来不可预测的延迟抖动。我的经验是如果大概知道数据量直接用reserve()预分配空间这个简单的操作往往能消除性能毛刺。std::map(红黑树) 与std::unordered_map(哈希表) 的选择这不是简单的“谁快谁慢”。红黑树保证O(log n)的稳定操作且迭代器稳定元素插入删除不会使其他元素的迭代器失效。哈希表平均O(1)但最坏情况O(n)且迭代顺序无序。在服务器开发中如果你需要频繁遍历且顺序重要或者内存分配器对性能敏感哈希表rehash可能带来开销红黑树可能是更稳妥的选择。我曾经在一个配置加载模块中因为使用了unordered_map且哈希函数不佳导致在特定key集合下性能急剧下降排查了很久。智能指针与资源管理std::unique_ptr和std::shared_ptr是现代C服务器开发避免原生内存泄漏的利器。但shared_ptr的滥用本身就会成为性能瓶颈。它的引用计数操作是原子的在高并发下会有开销。更隐蔽的问题是循环引用导致的内存泄漏这需要用std::weak_ptr来打破。设计模块接口时我的原则是能用unique_ptr表达独占语义的绝不用shared_ptr必须共享所有权时仔细审视对象生命周期优先考虑是否可以用依赖注入或单例等模式来避免。2.2 操作系统原理程序如何真正跑起来用C写服务器你几乎是在直接和操作系统对话。不理解OS就像司机不懂汽车发动机和传动系统车能开但出了怪声你根本不知道是哪里的问题。进程与线程服务器通常是多进程或多线程模型。你要理解进程的地址空间隔离、线程共享进程资源的含义。为什么全局变量需要加锁因为多个线程可能同时读写它。fork()系统调用在创建守护进程或进行进程池初始化时常用但要注意“写时复制”特性以及文件描述符的继承问题子进程如果不关闭不需要的fd可能会造成资源泄露或意想不到的通信。内存管理这是C服务器的核心战场。除了new/delete你更要关心栈与堆局部变量在栈上快速但大小有限动态内存new出来的在堆上灵活但有管理开销和碎片化问题。递归函数过深可能导致栈溢出而频繁的小内存分配释放会导致堆碎片。内存对齐CPU读取内存并非逐字节进行而是按块如64字节缓存行读取。如果一个int变量跨了两个缓存行读取它就需要两次内存访问性能下降。通过alignas关键字或编译器属性手动对齐关键数据结构尤其是多线程共享的数据能显著提升缓存效率。虚拟内存与缺页中断你的程序看到的是连续的虚拟地址空间由OS和MMU映射到物理内存。当访问的数据不在物理内存中时会触发缺页中断由OS从磁盘调入这个过程很慢。这就是为什么服务启动后有个“预热”阶段以及为什么要把经常一起访问的数据放在相邻的内存位置空间局部性以减少缺页中断。文件与I/O配置文件、日志都需要读写文件。要理解缓冲I/O如C的fprintf和直接I/O如openread/write的区别。缓冲I/O减少了系统调用次数但在某些需要强制刷盘的场景如写事务日志就不合适需要调用fflush()或使用O_SYNC标志。对于网络服务器更重要的是I/O多路复用机制select/poll/epoll这是实现高并发的关键技术我们会在网络部分详细讲。2.3 网络编程从Socket到协议设计这是服务器开发最外显的部分也是新手最容易上手但也最容易写出“玩具”代码的地方。Socket API基础socket(),bind(),listen(),accept(),connect(),read()/write(),close()。这几个函数必须像条件反射一样熟悉。但光会调用不够要理解背后的状态转移。比如listen()的第二个参数backlog它指定了已完成三次握手、等待应用层accept()的连接队列的最大长度。如果这个值设得太小在高并发连接涌入时即使你的服务器处理能力够也会因为队列满而导致客户端收到“连接拒绝”的错误。高并发I/O模型阻塞I/O最简单一个线程服务一个连接资源消耗大无法应对高并发。非阻塞I/O通过fcntl设置O_NONBLOCK标志调用会立即返回。需要程序轮询CPU空转严重。I/O多路复用这是主流方案。核心是使用一个系统调用select/poll/epoll来监听多个文件描述符上的事件。其中epoll是Linux下性能最好的机制。它采用事件驱动当被监听的fd上有事件可读、可写、错误发生时内核会通知应用程序而不是让应用程序去轮询成千上万个fd。一个简单的epoll边沿触发(ET)模式示例int epoll_fd epoll_create1(0); struct epoll_event ev, events[MAX_EVENTS]; // 将监听socket加入epoll监听读事件设置为边沿触发模式 ev.events EPOLLIN | EPOLLET; ev.data.fd listen_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, ev); while (true) { int nfds epoll_wait(epoll_fd, events, MAX_EVENTS, -1); for (int i 0; i nfds; i) { if (events[i].data.fd listen_fd) { // 有新连接到来必须循环accept直到返回EAGAIN while (true) { int conn_fd accept(listen_fd, ...); if (conn_fd -1) { if (errno EAGAIN || errno EWOULDBLOCK) { break; // 没有更多新连接了 } // 处理其他错误 break; } set_nonblocking(conn_fd); // 新连接设为非阻塞 ev.events EPOLLIN | EPOLLET | EPOLLONESHOT; // 使用ET模式并设置ONESHOT防止多线程下同一个fd被多个线程处理 ev.data.fd conn_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, ev); } } else { // 已连接socket有事件处理数据读写 handle_event(events[i].data.fd, events[i].events); } } }注意ET模式是高效但易错的。在ET模式下一个socket fd上的事件只会被通知一次直到该fd上有新的I/O活动发生。这意味着当你收到一个EPOLLIN可读事件后必须一次性把socket读缓冲区里的数据全部读完直到read()返回EAGAIN或EWOULDBLOCK错误。如果只读了一部分就返回剩下的数据不会再触发新的事件除非对端再次发送数据。这是很多ET模式bug的根源。协议设计与序列化TCP是流式协议没有消息边界。你发送的“Hello”和“World”在接收端可能被一次read调用全部收到也可能分两次。因此必须在应用层设计协议来界定消息。常见方法有定长协议每个消息长度固定。简单但不够灵活。分隔符协议用特殊字符如\r\n标记消息结束。文本协议常用如HTTP头但消息体本身不能包含分隔符。长度前缀协议最常用的二进制协议格式。在消息头中用一个固定长度的字段如4字节整数标明后面消息体的长度。// 一个简单的长度前缀协议消息结构 struct Message { uint32_t len; // 消息体长度网络字节序 char body[]; // 柔性数组实际的消息体数据 };序列化则涉及将结构体、对象转换成字节流。对于简单结构可以直接内存拷贝注意字节序和内存对齐。复杂对象则需要专门的序列化库如Protobuf、FlatBuffers。它们提供了跨语言、向前/向后兼容、高效的编解码能力是现代微服务间通信的标配。3. 核心组件设计与实现要点3.1 网络库核心Reactor模式与事件循环自己动手写一个简单的网络库是理解服务器原理最好的方式。现代高性能网络库如Netty, muduo的核心基本都是Reactor模式。Reactor模式本质是一种事件驱动模式。它包含几个关键角色Reactor事件分发器运行在一个或多个主线程中通过epoll_wait等待事件发生然后将事件分发给对应的处理器。Acceptor接受器专门处理监听socket上的新连接事件。Handler处理器负责处理已连接socket上的I/O事件读、写、错误。Event Loop事件循环是Reactor的核心一个不断“等待事件-处理事件”的循环。一个单线程Reactor的简化框架class EventLoop { public: void loop() { while (!quit_) { // 1. 等待事件 int num_events poller_-poll(kPollTimeMs, active_channels_); // 2. 处理活跃事件 for (Channel* channel : active_channels_) { channel-handleEvent(); // 这里会调用到Acceptor或Connection的回调 } // 3. 处理其他任务如定时器、跨线程调用的函数 doPendingTasks(); } } // ... 其他方法如添加/更新/删除监听事件添加任务等 };在这个模型里所有I/O事件的处理都在同一个线程内完成避免了锁的竞争性能很高但要求每个事件的处理必须是非阻塞且快速的否则会阻塞整个事件循环。多线程Reactor模型为了利用多核CPU常见的扩展是“one loop per thread”模式。即创建多个EventLoop线程通常与CPU核心数相当每个线程独立运行一个事件循环。Acceptor接收到新连接后通过某种负载均衡策略如轮询、取模将这个新连接分配给某个子EventLoop线程去管理其后续的所有I/O事件。这样多个连接被分摊到多个线程上处理提升了整体吞吐量。3.2 连接管理与资源池服务器需要同时维护成千上万个TCP连接高效的管理至关重要。连接对象设计每个连接对应一个Connection对象它封装了socket fd、读/写缓冲区、应用层协议解析器、状态连接中、已关闭等以及各种回调函数。这个对象的生命周期管理必须谨慎通常使用shared_ptr来管理并在其析构函数中确保socket被关闭。缓冲区设计网络I/O的特点是数据到达的不确定性。read()可能一次只读到半条消息也可能一次读到好几条。因此每个连接都需要自己的输入/输出缓冲区。输入缓冲区临时存放从socket读到的原始字节。协议解析器从输入缓冲区的头部开始解析解析完一条完整消息就将其移除剩下的数据等待下次解析。输出缓冲区当需要发送的数据量很大或者TCP发送窗口已满write返回EAGAIN时剩余的数据需要暂存在输出缓冲区中。当socket再次可写时EPOLLOUT事件触发再从输出缓冲区继续发送。 一个高效的缓冲区实现通常使用连续内存但能避免频繁拷贝。比如内部使用std::vectorchar但维护读索引和写索引。读取数据时移动读索引追加数据时移动写索引。当读索引移动到一定位置后可以一次性将已读数据的内存释放或移动到头部避免缓冲区无限增长。定时器与心跳为了检测死连接客户端异常崩溃、网络断开服务器需要心跳机制。每个连接可以关联一个定时器。如果在一定时间内如60秒没有收到该连接的任何数据定时器超时服务器就主动关闭连接。定时器的实现可以用时间轮、最小堆或红黑树。时间轮在大量定时器场景下效率很高它的思想是将时间划分为一个个刻度tick一个指针按固定频率前进每个刻度上挂着一个链表存放在该刻度超时的所有定时器。3.3 异步日志与性能统计服务器上线后日志是排查问题的生命线。但同步写日志每条日志都直接调用fwrite写入文件会阻塞业务线程成为性能瓶颈。异步日志其核心思想是生产者-消费者模型。业务线程生产者将日志消息写入一个内存缓冲区队列然后立刻返回。由一个或多个专用的后台日志线程消费者负责从队列中取出日志消息批量写入磁盘文件。这样业务线程的耗时仅仅是内存拷贝而将慢速的磁盘I/O操作转移到了后台线程。实现要点缓冲区设计通常采用双缓冲区或多缓冲区技术。准备两个缓冲区A和B。业务线程向A写当A写满后与空闲的B交换。后台线程负责将写满的缓冲区写入文件。交换操作需要加锁但频率很低缓冲区满时才交换锁竞争小。日志格式每条日志应包含固定信息时间戳微妙级、线程ID、日志级别INFO, WARN, ERROR等、源文件行号、实际消息。时间戳对于分析问题发生顺序至关重要。性能统计除了日志还需要实时监控服务器的关键指标如QPS每秒查询数、平均响应时间、连接数、各接口调用次数等。这些数据可以通过原子计数器来收集然后由一个单独的监控线程定期如每秒采样并输出到日志或发送到监控系统如Prometheus。4. 从理论到实践一个简易HTTP服务器的构建让我们把上面的理论串联起来构建一个最简单的静态HTTP/1.0服务器。它能监听端口接受连接解析HTTP GET请求并返回对应的静态文件。4.1 项目结构与核心类设计simple_http_server/ ├── src/ │ ├── EventLoop.cpp/h # 事件循环核心 │ ├── Channel.cpp/h # 封装文件描述符和事件回调 │ ├── EpollPoller.cpp/h # epoll的封装 │ ├── Acceptor.cpp/h # 接受新连接 │ ├── TcpConnection.cpp/h # TCP连接封装 │ ├── TcpServer.cpp/h # 服务器主类 │ ├── HttpContext.cpp/h # HTTP协议解析上下文 │ ├── HttpResponse.cpp/h # HTTP响应封装 │ └── main.cpp # 程序入口 └── build/核心流程TcpServer启动创建Acceptor和EventLoop。Acceptor在EventLoop中监听新连接事件。新连接到达Acceptor创建TcpConnection对象并为其分配一个HttpContext用于解析请求。TcpConnection的可读事件触发将收到的数据交给HttpContext解析。解析出一个完整的HTTP请求后回调用户设置的请求处理函数。处理函数根据请求路径读取静态文件构造HttpResponse。TcpConnection将HttpResponse序列化成字节流通过socket发送给客户端。如果是HTTP/1.0且没有Connection: keep-alive头则服务器主动关闭连接。4.2 HTTP协议解析的实现HTTP协议解析是状态机的一个经典应用。我们使用HttpContext类来保存解析状态。class HttpContext { public: enum HttpRequestParseState { kExpectRequestLine, // 正在解析请求行如 GET /index.html HTTP/1.1 kExpectHeaders, // 正在解析头部如 Host: www.example.com kExpectBody, // 正在解析消息体POST请求用 kGotAll, // 解析完成 }; bool parseRequest(Buffer* buf); // 从缓冲区解析数据 private: bool processRequestLine(const char* begin, const char* end); // 处理请求行 HttpRequestParseState state_; HttpRequest request_; // 解析结果存放处 };parseRequest方法会从缓冲区中尝试解析数据。它可能成功解析出一条完整请求返回true也可能数据不足返回false等待更多数据。解析请求行和头部时需要逐字节扫描寻找\r\n分隔符。这里要特别注意处理慢速客户端发送的“慢速loris”攻击——客户端以极慢的速度发送一个请求比如每10秒发送一个字节试图占住服务器连接。我们的解析器必须设置超时不能无限等待。4.3 静态文件发送与零拷贝优化处理一个GET /index.html请求我们需要读取磁盘上的index.html文件并发送。最朴素的做法是打开文件 - 读入内存缓冲区 - 将缓冲区内容写入socket。但这里存在一次不必要的内存拷贝从内核页缓存文件数据已在内核中到用户态缓冲区再从用户态缓冲区写回内核socket缓冲区。零拷贝技术可以避免这次拷贝。在Linux上可以使用sendfile()系统调用#include sys/sendfile.h ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它直接在两个文件描述符in_fd是打开的文件out_fd是socket之间传输数据内核全程负责数据不经过用户空间。这对于发送大文件如图片、视频性能提升巨大。然而sendfile有局限性它要求out_fd必须是一个socket且在某些旧内核上对普通文件支持不好。对于小文件或需要添加HTTP头部的场景可以先在用户态准备好HTTP头部然后使用writev()系统调用将头部和文件数据通过sendfile或readwrite组合发送也能减少系统调用次数。4.4 编译、运行与压力测试使用CMake管理项目编译。在main.cpp中我们创建服务器并注册请求处理回调#include TcpServer.h #include HttpResponse.h void onHttpRequest(const TcpConnectionPtr conn, const HttpRequest req) { // 1. 根据req.path()确定文件路径 std::string file_path www req.path(); if (file_path.back() /) file_path index.html; // 2. 判断文件是否存在、是否可读 // 3. 构造HttpResponse HttpResponse response; response.setStatusCode(HttpResponse::k200Ok); response.setStatusMessage(OK); response.setContentType(text/html); response.setBody(...); // 读取文件内容或使用sendfile路径 // 4. 发送响应 conn-send(response.toString()); } int main() { EventLoop loop; InetAddress listenAddr(8080); TcpServer server(loop, listenAddr, SimpleHttpServer); server.setHttpCallback(onHttpRequest); server.start(); loop.loop(); return 0; }编译运行后服务器就在8080端口监听。你可以用浏览器访问http://localhost:8080/或者用curl、abApache Benchmark进行测试。压力测试示例# 使用ab进行并发测试 ab -n 10000 -c 100 http://localhost:8080/index.html这个命令会模拟100个并发用户总共发送10000个请求。观察结果中的“Requests per second”每秒请求数和“Time per request”每个请求平均时间来评估服务器性能。一开始你可能只能得到几千QPS随着你应用ET模式、优化缓冲区、使用零拷贝这个数字会逐步提升。5. 进阶话题与生产环境考量5.1 多线程与锁的优化当“one loop per thread”模型中的业务逻辑变得复杂或者需要访问共享资源如数据库连接池、缓存时就不可避免地要用到锁。锁用不好性能会急剧下降。锁的粒度尽量缩小锁的持有范围。例如一个全局的配置字典如果只是偶尔更新频繁读取可以使用读写锁std::shared_mutex允许多个线程同时读。更新时使用std::unique_lock独占。无锁编程对于简单的计数器如QPS统计使用原子操作std::atomic完全避免锁。对于生产-消费队列可以考虑无锁队列基于CAS操作实现但实现复杂且并非在所有场景下都比有锁队列快需要仔细测试。线程局部存储有些数据根本不需要共享比如每个线程独立的随机数生成器、内存池。可以使用thread_local关键字让每个线程拥有该变量的独立副本彻底避免竞争。我踩过的坑曾经在一个项目中为了统计各个接口的调用耗时使用了一个全局的std::mapstd::string, Stats并用一个互斥锁保护。在高并发下这个锁成了热点QPS上不去。后来改为每个线程统计自己的数据定期如每秒合并到全局视图性能立刻提升了数倍。5.2 内存管理优化频繁的new/delete或malloc/free会导致锁竞争内存分配器内部有锁和内存碎片。对于高性能服务器自定义内存池是常见的优化手段。对象池对于频繁创建销毁的小对象如每个请求的上下文对象可以预先分配一大块内存并将其划分为固定大小的块。申请时从池中取一块释放时归还到池中避免向系统频繁申请。这不仅能减少锁竞争还能提高缓存命中率因为对象在内存中更紧凑。tcmalloc/jemalloc即使不自己写内存池也强烈建议使用第三方高效的内存分配器如Google的tcmalloc或Facebook的jemalloc来替代系统默认的glibc malloc。它们对于多线程场景下的内存分配有很好的优化能显著减少锁竞争和内存碎片。在Linux上通常通过设置环境变量LD_PRELOAD来加载它们。5.3 性能剖析与调试服务器性能不达标或者出现间歇性卡顿如何定位** profiling 工具**perfLinux内核自带的性能分析工具。perf top可以实时查看哪些函数占用CPU最多。perf record可以录制性能数据然后用perf report生成火焰图直观地看到函数调用栈和耗时分布。Valgrind特别是其中的callgrind工具可以生成详细的调用关系图和缓存模拟massif工具可以分析内存使用情况。但Valgrind会极大降低程序运行速度不适合在线分析。gperftoolsGoogle的性能工具套件包含CPU profiler和heap profiler使用相对简单。调试技巧核心转储当程序崩溃时通过ulimit -c unlimited开启核心转储然后用gdb加载core文件可以查看崩溃时的调用栈和变量值。日志分级设置不同的日志级别DEBUG, INFO, WARN, ERROR。在线上环境只打印WARN和ERROR在测试环境可以打开DEBUG输出更详细的信息帮助定位问题。网络抓包当怀疑网络问题时tcpdump是神器。tcpdump -i any port 8080 -w dump.pcap可以抓取所有8080端口的流量然后用Wireshark图形化工具分析能看到三次握手、数据传输、是否有关闭异常等所有细节。6. 常见陷阱与排查实录6.1 连接泄漏与资源耗尽现象服务器运行一段时间后无法建立新连接accept失败errno为EMFILE打开文件数过多或ENFILE。用lsof -p pid查看进程打开的文件描述符数量巨大。原因与排查连接未关闭这是最常见的原因。服务器在处理完请求后没有正确调用close()关闭socket。特别是在异常处理路径如解析协议出错、业务逻辑抛出异常中很容易漏掉关闭操作。关闭姿势不对TCP连接是双向的。调用close()会同时关闭读写两个方向。但有时你需要更精细的控制比如只关闭写方向发送FIN等待对方关闭这需要用到shutdown(fd, SHUT_WR)。错误地使用shutdown也可能导致连接状态卡住。文件描述符泄漏除了socket打开的文件open、管道pipe等如果没有关闭也会占用fd。解决使用RAII资源获取即初始化技术封装资源。在C中让连接对象的析构函数负责关闭socket。这样只要对象被正确销毁如通过shared_ptr引用计数归零资源就会自动释放。确保所有代码路径包括异常都能到达资源释放点。可以使用try-catch块在catch中清理资源。设置进程级别的文件描述符数量限制ulimit -n只是一个临时屏障根本还是要解决泄漏问题。6.2 惊群问题现象在使用多进程模型如pre-fork时当一个新连接到来所有子进程都被唤醒但最终只有一个进程能accept成功其他进程白忙活一次造成CPU资源浪费。原因在Linux 2.6版本之前多个进程/线程在同一个socket上调用accept或epoll_wait当连接到来时内核会唤醒所有等待的进程。解决使用SO_REUSEPORT选项Linux 3.9这是现代解决方案。允许多个进程或线程绑定到同一个IP和端口。内核会负责将新连接均匀地分发给这些监听者从根源上避免了惊群。在调用bind()之前对socket设置此选项int optval 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, optval, sizeof(optval));使用互斥锁在老版本内核中常见的做法是让所有子进程在accept前先获取一个全局锁文件锁或进程间互斥锁只有拿到锁的进程才能去accept接受完连接后释放锁。6.3 缓冲区与流量控制现象服务器发送数据速度远快于客户端接收速度导致服务器内核的socket发送缓冲区积压最终耗尽内存或者触发TCP的流量控制窗口变为0发送端停止发送。原因网络状况不对称或客户端处理能力不足。如果服务器不顾一切地往输出缓冲区写数据而TCP窗口已满数据会在应用层输出缓冲区或内核发送缓冲区中堆积。解决监听可写事件这是关键。不要一直无脑地调用write或send。当write返回EAGAIN/EWOULDBLOCK时说明内核发送缓冲区已满。此时应该停止写入并将socket的EPOLLOUT事件加入到epoll监听中。当TCP窗口有空闲内核缓冲区有空间了EPOLLOUT事件会被触发这时再继续写入剩余的数据。写入完成后要记得将EPOLLOUT事件从监听中移除否则它会一直触发因为socket在可写状态。应用层流量控制对于更上层的协议可以设计自己的ACK机制或滑动窗口防止生产者服务器过快压倒消费者客户端。6.4 协议解析与安全现象服务器被恶意请求打挂或者解析到一半崩溃。原因与防范缓冲区溢出解析请求行或头部时如果使用固定大小的数组且没有边界检查恶意客户端发送一个超长的行就可能覆盖栈上的其他数据导致程序崩溃或被利用执行恶意代码。必须对所有内存拷贝操作进行边界检查使用strnlen,memcpy等带长度参数的函数。慢速攻击如前所述客户端以极慢速度发送请求试图耗尽服务器的连接资源。解决方案是为每个连接设置一个空闲超时。如果在一定时间内没有完成请求解析或收到任何数据就主动断开连接。路径遍历攻击客户端发送请求如GET /../../../etc/passwd HTTP/1.1试图访问服务器上的敏感文件。在拼接文件路径前必须对请求的路径进行规范化处理并限制其不能跳出服务根目录如www目录。可以使用realpath()函数来解析绝对路径并检查其前缀是否在允许的目录内。写服务器程序尤其是网络协议解析部分必须时刻保持“不信任任何外部输入”的心态进行严格的校验和边界防护。