2026/10/20 18:33:07

手写并发文件传输服务:一次搞懂Linux文件IO、线程池与Socket

手写并发文件传输服务:一次搞懂Linux文件IO、线程池与Socket 1. 项目底座为什么用并发文件传输服务把这五块内容全串起来C语言学到一定程度很多人都会卡在同一个地方单独看每个知识点都觉得懂文件IO会读写、进程线程能写出几个demo、Socket也能照书敲出一个回声服务器可真要把这些模块组合成一个能跑、能扛、能编译多文件的项目立刻就不知道从哪下手了。我这次就是为了治这个毛病把Linux系统编程里的文件IO、进程线程、Socket和Makefile全部串起来做了一遍最后落地成一个并发文件传输服务。1.1 每个模块在项目里的真实角色这个项目不算花哨但好在它每一个模块都是被需要的不是硬凑上去表演的模块在项目中的角色你必须真正掌握的点文件IO服务端读写磁盘文件、客户端读写本地文件是整个传输服务的数据来源和落盘出口open/read/write/close、缓冲区大小、返回值处理、权限位、fsync进程/线程服务端要同时服务多个客户端不能一个一个排队并发模型选型、线程同步、条件变量、线程安全、资源回收Socket客户端和服务端之间的数据传输通道TCP连接管理、字节序、粘包半包、完整收发、超时处理Makefile多目录多源文件工程的组织和构建变量、模式规则、自动头文件依赖、链接参数、调试/发布配置文件传输这个场景特别有意思的地方在于网络IO和文件IO永远是一对出现的。客户端收文件是从socket读、往磁盘写服务端发文件是从磁盘读、往socket写。两个方向都有IO边界和缓冲区问题任何一个环节写得不严谨传大文件时就会出现传完了但md5对不上这种让人头皮发麻的诡异问题。1.2 自定义应用层协议不依赖框架才能理解框架既然是练手项目我一开始就决定不用任何网络库完全基于POSIX socket API自己定义一套简单的应用层协议。一方面是想把底层的封包、解包逻辑亲手走一遍另一方面是日后接触正式的网络框架时不至于连帧消息边界这些概念是从哪来的都不知道。协议设计很简单一个固定格式的头加上变长数据体#define PROTO_MAGIC 0x5A5A #define CMD_UPLOAD 0x01 // 客户端 - 服务端上传文件 #define CMD_DOWNLOAD 0x02 // 客户端 - 服务端下载文件 #define CMD_RESP_OK 0x10 // 服务端 - 客户端成功响应 #define CMD_RESP_ERR 0x11 // 服务端 - 客户端失败响应 #define FILE_NAME_MAX 64 struct proto_header { uint16_t magic; // 魔数用于快速校验 uint16_t cmd; // 命令字 uint32_t body_len; // 整个包体的长度字节数 };魔数的意义是让接收端可以快速丢弃无效数据流避免把垃圾数据解析成命令。body_len字段是整个协议的核心收包流程全靠它来确认一个完整消息到底有多长。这套设计最大的收益是当你踩到粘包、半包这些坑的时候你清楚地知道坑在哪儿因为你亲手设计了解决它们的机制而不是稀里糊涂地调一个现成框架的接口。2. 文件IO层读写文件没你想的那么简单文件IO这块教科书上讲的和实际工程里用到的差距相当大。如果你只是跟着教材抄过open、read、write的示例那你大概率会在这层踩坑。2.1 open的flags与权限位服务端写文件写出一堆没权限的货服务端在接收上传文件时需要调用open创建新文件。我的第一个版本长这样int fd open(save_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);看起来没毛病但我实测时才注意到服务器进程工作目录下新建的文件权限变成了0640而不是0644。排查一圈才发现是进程的umask是00270644 ~0027 0640group那一组完全没有读权限。结果就是我本机测试没问题换到另一个环境跑客户端下载下来的文件别人都没法读。处理方案有两种在服务端启动时调用umask(0)或者创建文件后主动chmod。我最后选择的是启动时设umask(0)然后open时明确写清楚mode这样权限完全由代码控制不依赖环境。另外还有一点O_TRUNC配合O_WRONLY使用意味着如果同名文件已经存在会被直接清空重写。对下载场景无所谓但对上传场景这是有风险的最好先用access()判断文件是否已存在再决定是覆盖还是报错。2.2 read/write的返回值需要逐字节处理基础良好的同学都知道read返回值有三态大于0表示读取了N字节等于0表示到达文件末尾小于0是出错。但真到写代码时很多人会犯一个很隐蔽的错误write一次性写入失败就认为整个包发送失败了。实际上对阻塞IOwrite可能只写入了一部分数据就返回了这种情况必须循环把剩余字节写完。我写了一个通用的write_all来保证文件IO的完整写入和Socket层的完整收发逻辑是互相对应的ssize_t write_all(int fd, const void *buf, size_t len) { const char *p (const char *)buf; size_t left len; while (left 0) { ssize_t n write(fd, p, left); if (n 0) { if (errno EINTR) // 被信号打断重试即可 continue; return -1; } if (n 0) break; p n; left - n; } return len - left; }EINTR这个小细节我在初次版本里完全忽略了。当时测试时偶尔会出现一次大文件传输莫名其妙少了几个字节的问题后来用strace追踪才发现write调用返回了-1 EINTR而代码把它当成写入失败把连接关掉了。处理规则很简单遇到EINTR继续重试其他错误才是真的异常。2.3 缓冲区大小64KB比4KB快了不止一点文件拷贝类任务的性能大头是系统调用次数每一次read和write都会陷入内核。缓冲区只有4KB时传一个100MB的文件需要2.5万次read和2.5万次write换成64KB缓冲区系统调用次数直接降到原来的1/16。我专门拉了一组粗测数据做对比在同一台机器上通过回环地址传输100MB文件buffer大小耗时备注4KB约1.8s系统调用开销大且读写明显慢16KB约1.2s提升明显64KB约0.7s传输速率基本稳定下来1MB约0.65s提升不大内存开销变大所以项目里统一用64KB缓冲区这是一个甜点值。磁盘写完后数据还在内核页缓存里如果亟需落盘在文件传输完毕时要调fsync否则机器突然断电时可能丢数据。这个对普通测试项目不是必须的但作为习惯一定要有。2.4 文件完整性校验别等传完了才发现内容不对我在项目中做的校验分两层第一层是服务端和客户端都统计实际传输的字节数和协议头里声明的文件大小比对第二层是用md5sum对比原文件和传输后文件的哈希值。第一次测试时我就遇到字节数一致md5不一致的情况查了半天才发现是缓冲区的最后一次read只读到部分数据但代码错误地假设read一次一定读满buffer导致尾部塞进了上一次残留的脏数据。这种Bug用眼睛很难看出来但用校验工具一测就露馅。所以我的建议是任何文件传输类项目完整性校验一定要从一开始就加上不要等怀疑出问题才加到了排查阶段就晚了。3. 并发模型从单线程排队到线程池的演进文件传输服务最核心的挑战是并发。我先写了一个最朴素的单线程版本再一步步演进到线程池每一步都有明确的动机和实测感受。3.1 单线程基线版本第二个客户端永远在等第一个可运行版本是单线程循环while (1) { int conn_fd accept(listen_fd, ...); handle_client(conn_fd); // 完整处理完一个客户端的下载请求 close(conn_fd); }逻辑完全正确但用户体验一塌糊涂。本地测试客户端A下载一个大文件时客户端B连接上来后服务端根本没有反应因为accept调用根本还没执行到。文件下载这类任务耗时主要在网络传输最直觉的改进思路就是每个连接单独派一个执行体去处理。3.2 多进程方案实测简单但有边界当时我第一反应是用多进程网上很多经典服务器示例也是这么写的pid_t pid fork(); if (pid 0) { close(listen_fd); handle_client(conn_fd); close(conn_fd); exit(0); } close(conn_fd); // 父进程关掉这个连接fd这套方案跑起来后确实解决了并发问题简单粗暴。进程之间完全隔离子进程处理过程中崩溃不会拖垮整个服务端。但它也有几个让我比较头疼的边界每个连接都要fork一次1000个连接就是1000次进程创建开销比线程大不少。想统计当前在线客户端数总共服务过多少请求父子进程之间共享状态很麻烦要靠IPC或共享内存。如果多个子进程同时阻塞在accept上会出现惊群现象虽然有内核解决方案但我这个项目不想引入太复杂的东西。3.3 线程池方案最终选型和线程安全细节多进程方案基础上稍作调整就是多线程但线程版本真正考验人的是线程安全。我的最终方案是主线程accept然后把连接fd塞进一个任务队列一组工作线程从队列里取任务处理也就是典型的线程池模型。这里有一个之前没想明白、后来才摸透的关键点任务队列的锁粒度。用互斥锁保护队列本身是必须的但工作线程处理单个客户端连接的全过程绝不能加全局锁否则多个客户端又被串行化了。正确的做法是只有队列入队、出队这两个短操作需要加锁后续的网络传输全部在锁外进行。struct task { int conn_fd; struct sockaddr_in peer_addr; }; pthread_mutex_t queue_lock PTHREAD_MUTEX_INITIALIZER; pthread_cond_t queue_cond PTHREAD_COND_INITIALIZER; task_queue_t work_queue; void *worker_main(void *arg) { while (1) { pthread_mutex_lock(queue_lock); while (task_queue_empty(work_queue)) pthread_cond_wait(queue_cond, queue_lock); struct task t task_queue_pop(work_queue); pthread_mutex_unlock(queue_lock); handle_client(t.conn_fd); // 锁外处理连接 close(t.conn_fd); } }条件变量这套机制课本上讲的是等待-通知但实际写起来最容易犯的错是漏掉锁内循环判断。比如队列为空时多个线程同时被唤醒其中一个取走了任务其他线程再次判断队列为空就要继续睡眠。如果用if而不是while线程就会带着空任务向下执行最终读到未定义数据。这个坑我是在并发压测时用AddressSanitizer抓出来的报错信息非常经典。另外统计信息我用了一个全局原子变量static _Atomic long g_total_requests 0; // 在handle_client入口处 atomic_fetch_add(g_total_requests, 1);这比每次统计都拿互斥锁要轻量得多也足够满足需求。3.4 线程资源和SIGPIPE两个容易被忽略的坑线程默认栈大小是8MB我的服务器只维护8个工作线程看起来无所谓但如果连接数增多每连接一线程的模型下过千连接就是8GB虚拟内存压力非常大。线程池模式里线程数固定这个压力还在可控范围但我还是用pthread_attr_setstacksize把工作线程栈调到了1MB足够处理文件IO逻辑也显著降低了内存占用。SIGPIPE这个问题是这个项目让我印象最深的坑之一当客户端断开连接后服务端继续往这个socket写数据内核会发出SIGPIPE信号而该信号的默认行为是终止进程。也就是说你的服务器会在某个客户端异常退出时整进程挂掉不是崩溃而是直接被信号杀了。解决办法很简单在main函数启动时加一句signal(SIGPIPE, SIG_IGN);忽略之后对已断开连接的写操作会返回EPIPE错误交给业务代码处理即可。这一步不做高并发测试时服务器撑不过五分钟。4. Socket通信层从demo级到能扛住真实网络场景很多Socket教程写的是连接、收发、关闭三步走看起来很简单但真实的网络环境远比demo复杂半包、粘包、客户端半路断开、网络闪断都得靠代码一层层兜住。4.1 基础封装的三个关键点第一个关键点是SO_REUSEADDR。服务端正常退出后之前监听的端口会处于TIME_WAIT状态紧接着重启服务端bind会失败提示地址已被占用。这是因为TCP连接关闭后的TIME_WAIT要持续一段时间。用setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, ...)可以解决这个重启问题这在开发阶段几乎是必备的。第二个关键点是完整收发。send和recv都不能保证一次调用就把整段数据传完尤其是网络繁忙时所以必须封装成循环收发int send_all(int fd, const void *buf, size_t len) { const char *p (const char *)buf; size_t left len; while (left 0) { ssize_t n send(fd, p, left, 0); if (n 0) { if (errno EINTR) continue; return -1; } p n; left - n; } return 0; }recv_all的实现逻辑与send_all对称。这套封装从文件IO那里沿用过来思路完全一致。第三个关键点是字节序。TCP协议规定多字节整数统一用网络字节序大端本地机器一般是小端。协议头里的body_len我用htonl编码、ntohl解码。我一开始忘了做这一步结果就出现了客户端声明的文件长度是0服务端却解析出一个天文数字的怪事一下浪费了半天时间才定位到。4.2 粘包与半包TCP是字节流不是消息流TCP是流式协议它只保证字节的有序可靠传输不保证单个send的数据块与单个recv的数据块一一对应。客户端连续发送两次数据内核可能把两个包合并到一次recv里返回这是粘包反过来一个大数据包可能分多次到达recv只读到了其中一部分这是半包。处理方案很常规通过消息头里的body_len按需读取。服务端接收一个完整请求的流程是int recv_proto_msg(int fd, struct proto_header *hdr, uint8_t **body) { // 1. 先完整接收头部头部大小固定 if (recv_all(fd, hdr, sizeof(*hdr)) 0) return -1; // 2. 头部字段转主机字节序 hdr-body_len ntohl(hdr-body_len); // 3. 根据body_len分配缓冲区循环接收数据体 *body malloc(hdr-body_len); if (recv_all(fd, *body, hdr-body_len) 0) { free(*body); return -1; } return 0; }这一套做完粘包和半包就不会再成为问题。如果收到的字节数不够一个头部recv_all会一直等直到完整头部到达或者连接异常返回。4.3 超时控制与异常客户端处理真实场景里客户端不一定规规矩矩比如连上了但不发送任何数据工作线程就一直阻塞在recv上一个恶意或者单纯有Bug的客户端就能白白占住一个工作线程。为此我引入了接收超时struct timeval tv; tv.tv_sec 60; tv.tv_usec 0; setsockopt(conn_fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));设置之后如果60秒内没有收到任何数据recv会返回-1errno是EAGAIN或EWOULDBLOCK。这时候可以判定该连接异常关闭连接释放线程资源。同理发送方向也可以设置SO_SNDTIMEO防止对端接收窗口阻塞把发送线程挂死。另一个经常被忽略的问题是断连检测。对端通过close正常关闭连接时recv返回0对端进程崩溃或网络异常硬断开时recv返回-1errno是ECONNRESET。两种情况下都要关掉连接fd和释放已分配的内存否则泄漏会越来越严重。4.4 大文件传输的落地细节协议头设计之初我只放了文件名长度但很快发现必须把文件大小也放进去。服务端下载文件时用fstat获取文件真实大小发给客户端客户端上传文件时先在协议体里发送文件大小服务端据此判断后续要收多少字节。接收文件和服务端下载时我都坚持先写临时文件传输确认完整后再rename成正式文件。这个习惯是从下载工具那边学来的可以避免传输到一半时服务端异常重启留下一个残缺文件把正式文件污染掉。实际连接中客户端每次下载完就对文件做一次md5校验整个传输链路有没有出问题一目了然。传输进度和限速这块我在客户端加了一个简单的进度打印下载时每收到1MB打印一次百分比。服务端因为要对多客户端公平我没有做太复杂的限速只是通过usleep控制每秒最大发送字节数。对于这个项目的训练目的来说能把基本的传输闭环做扎实比叠加花样的优先级更高。5. Makefile组织工程与调试三板斧项目做到这里源文件已经有common、client、server三个目录十来个头文件和源文件。每次手动敲gcc是不现实的我用一份手写的Makefile完成了全部构建工作。5.1 手写Makefile的基本结构工程目录我按功能拆成三个子目录project/ ├── Makefile ├── common/ │ ├── proto.h │ └── proto.c ├── server/ │ ├── server.c │ ├── worker.c │ └── worker.h └── client/ └── client.cMakefile的核心思路是每个.c文件编译成对应的.o文件最后链接生成两个可执行程序。我用变量和模式规则避免手写每一个文件的编译命令CC : gcc CFLAGS : -Wall -Wextra -O2 -g LDFLAGS : -lpthread COMMON_SRC : $(wildcard common/*.c) SERVER_SRC : $(wildcard server/*.c) CLIENT_SRC : $(wildcard client/*.c) COMMON_OBJ : $(COMMON_SRC:.c.o) SERVER_OBJ : $(SERVER_SRC:.c.o) $(COMMON_OBJ) CLIENT_OBJ : $(CLIENT_SRC:.c.o) $(COMMON_OBJ) all: server client server: $(SERVER_OBJ) $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS) client: $(CLIENT_OBJ) $(CC) $(CFLAGS) -o $ $^ $(LDFLAGS) %.o: %.c $(CC) $(CFLAGS) -c -o $ $ clean: rm -f server client $(COMMON_OBJ) $(SERVER_OBJ) $(CLIENT_OBJ)$(wildcard ...)会自动收集目录下所有.c文件新增源文件不需要改Makefile%.o: %.c模式规则把每个源文件单独编译。链接时-lpthread必须写很多初学者写完线程代码但忘了链线程库编译器一路报undefined reference to pthread_create其实就是少了这一步。调试和发布版本我用宏开关区分开发时开-g -DDEBUG -O0方便断点和日志发布时切到-O2 -DNDEBUG去掉调试日志。这里我踩过一个细节坑服务端日志开关如果控制得不好每传一个文件都打印一大屏调试信息会严重影响吞吐所以日志模块我加了分级开关只有DEBUG宏打开时才输出调试级日志。5.2 头文件依赖自动管理手写Makefile最容易踩的坑是头文件改了包含它的.c文件不会重新编译。解决方案是让gcc生成.d依赖文件通过-MMD -MP把每个源文件对头文件的依赖关系记录下来CFLAGS -MMD -MP DEPS : $(COMMON_SRC:.c.d) $(SERVER_SRC:.c.d) $(CLIENT_SRC:.c.d) -include $(DEPS)这样只要任何头文件被修改依赖它的源文件就会自动重新编译省去了手动make clean的烦恼。这个特性我强烈建议刚学Makefile的人第一件事就加上能省掉大量无意义的重复劳动。5.3 gdb、strace、valgrind三板斧到了调Bug环节单靠printf效率太低我把三板斧的用法整理了一遍项目后期基本全靠这三样定位问题。第一板斧是gdb尤其是多线程程序。程序挂住或崩溃时先用gdb ./server core打开core文件再执行thread apply all bt把所有线程的调用栈打出来。有一次服务端疑似死锁就是靠这个看到两个工作线程都卡在条件变量等待上、主线程卡在accept上很快就定位到是队列通知丢失。第二板斧是strace。strace -p pid可以看到进程每次系统调用。怀疑网络收发异常时strace -f -e tracenetwork -p pid能直接看到accept、recvfrom、sendto的参数和返回值错误码一目了然。我定位EINTR问题就是靠它看到write(-1, EINTR)。第三板斧是valgrind。valgrind --leak-checkfull --show-leak-kindsall -v ./server可以在压测结束后列出所有内存泄漏点。这个项目前期泄漏非常严重主要集中在连接关闭时忘了free收包缓冲区、线程池退出时没有回收线程资源。valgrind把每一处泄漏的文件和行号都打出来改起来非常快。5.4 我踩过并希望你别再踩的四个坑这四个坑是我这个项目里最耗时、也最典型的单列出来给后来者参考字节序没转就解析长度字段协议头里的长度字段必须统一用网络序。忘了ntohl客户端声明的文件大小可能被解析成上千倍的错误值malloc直接就失败了。send没有处理短写就返回成功测试环境网络好时一切正常一旦数据量大或者网络波动send只发了一半就返回导致对端一直等后续数据直接卡死。必须用send_all循环发完。条件变量用if判断而不是while多个工作线程同时被唤醒时较晚获得锁的线程拿到的任务可能已经被取走。用while循环重新检查队列状态这是POSIX条件变量使用的基本功忽略它的后果是偶发性越界访问。Makefile忘了链接-lpthread这是最让人哭笑不得的坑。编译器提示找不到pthread_create时第一反应是去检查头文件结果最后发现只是链接参数少了。建议一上来就把LDFLAGS写好。5.5 端到端压测脚本最后写了一个简单的bash脚本做并发验证一次性起5个客户端同时从服务端下载同一个100MB文件全部完成后逐一做md5校验#!/bin/bash for i in 1 2 3 4 5; do ./client download bigfile.bin ./out_$i.bin done wait for i in 1 2 3 4 5; do md5sum bigfile.bin out_$i.bin done第一次跑这脚本时5个文件打印出的md5全部一致那一刻才敢说这个并发文件传输服务真正站住了脚。之后再跑上传方向、异常断开、超时无响应这些边界场景心里有底多了因为整个传输链路的每一层都已经逐项验证过。最后再分享一点个人体会这个项目的正确打开方式不是把上面的代码抄一遍就完事而是先让自己写出一个单线程版本跑起来感受一下什么叫第二个用户在等然后改成每连接一个线程体会并发的爽快和资源开销最后再上线程池踩一遍条件变量和锁的坑。这个演进过程每一步踩到的坑都是Linux系统编程里最值钱的经验积累。做完这个项目之后如果你想继续深入可以试着把线程池换成select或epoll多路复用模型或者给传输加上加密和断点续传那种改造带来的认知冲击不会比这次小。