目录
- 一、搭建 TCP 服务器骨架
- 服务器代码
- 测试
- 二、支持多客户端并发
- 三、线程模型
- 核心思路
- 为什么使用 detach
- 输出为什么会错乱
- 四、函数重构
- 重构后的结构
- 五、FTP 协议基础
- 控制连接
- 数据连接
- 六、命令解析
- 行缓冲区
- 命令解析
- 为什么要转大写
- 七、PASV 被动模式
- 为什么需要数据连接?
- PASV 工作流程
- 第一步
- 第二步
- 动态端口
- 八、LIST 命令
- 为什么 LIST 要 accept
- 遍历目录
- 安全问题
- 九、文件下载(RETR)
- 基础实现
- 十、文件上传(STOR)
- 为什么 recv 返回 0 表示结束?
- 十一、用户身份认证
- 命令权限控制
- 十二、路径系统设计
- 十三、路径规范化 Bug
- 修复后的思路
- normalize 的意义
- 十四、sendfile 零拷贝优化
- sendfile
- 优势
- 1. 更少的 CPU 消耗
- 2. 更少的数据拷贝
- 3. 更少的上下文切换
- 实测
- 十五、断点续传(REST)
- Session 状态
- 下载续传
- 上传续传
- 测试结果
- 十六、线程池优化
- 十七、线程池模型
- condition_variable
- 为什么任务队列需要 mutex?
- 十八、epoll 高并发模型
- epoll 的核心优势
- 1. 事件驱动
- 2. 红黑树管理 fd
- 3. 回调机制
- 十九、epoll 工作流程
- 创建 epoll
- 注册 fd
- 等待事件
- 非阻塞 IO
- 二十、总结
一、搭建 TCP 服务器骨架
网络服务器,基本步骤:
- socket
- bind
- listen
- accept
- recv/send
先实现一个最小 TCP Server
服务器代码
intmain(){intserver_fd,client_fd;structsockaddr_inserver_addr,client_addr;socklen_t client_len=sizeof(client_addr);charbuf[1024];server_fd=socket(AF_INET,SOCK_STREAM,0);if(server_fd==-1){cerr<<"socket failed"<<endl;return1;}intopt=1;setsockopt(server_fd,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));server_addr.sin_family=AF_INET;server_addr.sin_addr.s_addr=INADDR_ANY;server_addr.sin_port=htons(2100);if(bind(server_fd,(structsockaddr*)&server_addr,sizeof(server_addr))==-1){cerr<<"bind failed"<<endl;close(server_fd);return1;}if(listen(server_fd,5)==-1){cerr<<"listen failed"<<endl;close(server_fd);return1;}cout<<"listening "<<2100<<"..."<<endl;client_fd=accept(server_fd,(structsockaddr*)&client_addr,&client_len);if(client_fd==-1){cerr<<"accept failed"<<endl;close(server_fd);return1;}constchar*greeting="220 ready\r\n";send(client_fd,greeting,strlen(greeting),0);while(true){memset(buf,0,sizeof(buf));intcount=recv(client_fd,buf,sizeof(buf)-1,0);if(count<=0){break;}buf[count]='\0';cout<<"[Received] : "<<buf;}close(client_fd);close(server_fd);}测试
服务端:
./server客户端:
telnet127.0.0.12100输入:
hello服务器即可收到数据
二、支持多客户端并发
当前服务器只能同时处理一个客户端
因为:
accept->recv->recv->recv会一直阻塞。
所以需要并发处理。
最简单的方法:
- 主线程 accept
- 每个客户端一个线程
三、线程模型
核心思路
主线程: accept 新连接 工作线程: 专门处理客户端代码:
threadthr(HandleClient,client_fd,client_ip,client_port);thr.detach();为什么使用 detach
thr.detach();让线程独立运行。
否则:
thr.join();会阻塞主线程。
这样服务器就无法继续 accept 新客户端。
输出为什么会错乱
多个线程同时 cout:
helloabc123日志会交叉。
因此需要:
mutex cout_mtx;配合:
lock_guard<mutex>保护输出。
四、函数重构
随着代码变多,main 会越来越混乱。
于是把逻辑拆分:
- CreateServerSocket
- AcceptClient
- SendGreeting
- ReceiveClientData
- HandleClient
- RunServer
这样整个结构会非常清晰。
重构后的结构
RunServer ├── CreateServerSocket ├── AcceptClient └── HandleClient ├── SendGreeting └── ReceiveClientData五、FTP 协议基础
控制连接
负责发送命令:
USER PASS LIST RETR STOR数据连接
真正传输文件
六、命令解析
FTP 命令格式:
COMMAND arg\r\n例如:
USER alice\r\n因此需要:
- 解决 TCP 粘包
- 按行解析
- 提取命令和参数
行缓冲区
string line_buf;每次 recv 后:
line_buf.append(buf,count);然后寻找:
\r\n作为一条完整 FTP 命令。
命令解析
size_t space=cmd.find(' ');string op=cmd.substr(0,space);string arg=...例如:
USER alice解析后:
op = USER arg = alice为什么要转大写
FTP 命令大小写不敏感。
因此:
transform(op.begin(),op.end(),op.begin(),::toupper);统一转换。
七、PASV 被动模式
为什么需要数据连接?
FTP 协议规定:
- 控制连接:发送命令
- 数据连接:传文件
LIST / RETR / STOR 都必须走数据连接。
PASV 工作流程
第一步
客户端发送:
PASV第二步
服务器:
- 创建新的监听 socket
- 随机绑定端口
- 返回端口号
例如:
227 Entering Passive Mode (127,0,0,1,19,136)其中:
port = 19 * 256 + 136动态端口
data_addr.sin_port=htons(0);端口设置为 0。
让系统自动分配。
再通过:
getsockname获取真实端口。
八、LIST 命令
LIST 的流程:
客户端:PASV 服务器:返回数据端口 客户端:连接数据端口 客户端:LIST 服务器:accept 数据连接 服务器:发送目录内容为什么 LIST 要 accept
因为 PASV 时:
服务器只是 listen。
真正的数据连接:
需要客户端主动 connect。
因此 LIST 时必须:
accept(data_listen_fd,...)遍历目录
DIR*dir=opendir(path.c_str());然后:
readdir(dir)获取目录项。
安全问题
if(path.find("..")!=string::npos)防止目录穿越。
否则客户端可能访问:
../../etc/passwd九、文件下载(RETR)
FTP 下载流程:
PASV RETR filename服务器:
- 打开文件
- accept 数据连接
- send 文件内容
基础实现
while((n=fread(buf,1,sizeof(buf),fp))>0){send(data_client_fd,buf,n,0);}磁盘 -> 用户态 -> 内核态 -> Socket会发生多次数据拷贝。
后面会优化。
十、文件上传(STOR)
上传流程:
PASV STOR filename服务器:
recv->fwrite不断接收客户端数据。
为什么 recv 返回 0 表示结束?
因为客户端关闭了数据连接。
FTP 文件传输结束,本质上就是:
关闭 data socket十一、用户身份认证
FTP 标准流程:
USER xxx PASS xxx引入:
boollogged_in和:
string username命令权限控制
在 LIST / RETR / STOR 前增加:
if(!logged_in)否则:
530 Please login with USER and PASS.十二、路径系统设计
FTP 服务器需要维护:
当前工作目录例如:
/ /wo /wo/test因此需要实现:
- CWD
- PWD
- 路径规范化
十三、路径规范化 Bug
最开始出现的问题:
//wo导致:
LIST 失败原因是:
res+="/"+p;重复拼接了/。
修复后的思路
核心思想:
- 先拆路径
- 再统一拼接
例如:
/wo/test拆成:
[wo, test]最后统一生成。
normalize 的意义
它不仅解决:
//问题。
还统一处理:
. ..所有路径逻辑统一入口处理。
否则后面会出现大量边界 Bug。
十四、sendfile 零拷贝优化
最开始的下载流程:
磁盘 -> 内核缓冲区 -> 用户缓冲区 -> Socket缓冲区存在:
- 用户态 / 内核态切换
- 多次内存拷贝
sendfile
Linux 提供:
sendfile实现:
文件 -> Socket直接在内核完成。
代码:
sendfile(data_client_fd,file_fd,&offset,remaining);优势
1. 更少的 CPU 消耗
2. 更少的数据拷贝
3. 更少的上下文切换
实测
使用:
ddif=/dev/zeroof=bigfile.binbs=1Mcount=100生成 100MB 文件。
测试下载:
lftp-p2100127.0.0.1-e"get bigfile.bin; quit"100MB 文件几乎瞬间完成。
十五、断点续传(REST)
FTP 支持:
REST offset表示:
从 offset 开始继续传输Session 状态
新增:
structClientSession{off_t restart_offset=0;};每个客户端独立维护。
下载续传
核心:
off_t offset=session.restart_offset;然后:
sendfile(...,&offset,...)sendfile 会自动更新 offset。
上传续传
上传稍微复杂。
需要:
fseek(fp,session.restart_offset,SEEK_SET);把文件指针移动到断点位置。
测试结果
中断上传后重新上传:
350 Restarting at xxx然后继续传输。
最后 md5 校验一致。
说明断点续传正确。
十六、线程池优化
前面的模型:
一个客户端 -> 一个线程问题:
线程创建开销很大。
高并发时:
- 线程数量暴涨
- 上下文切换严重
- CPU 被调度拖死
十七、线程池模型
核心思想:
预先创建线程任务来了:
放入任务队列工作线程不断消费任务。
condition_variable
这里的关键:
cv.wait(lock,...)线程没有任务时:
睡眠避免空转浪费 CPU。
为什么任务队列需要 mutex?
因为:
多个线程会同时:
- push
- pop
必须加锁。
十八、epoll 高并发模型
epoll 的核心优势
1. 事件驱动
只返回:
真正活跃的 fd2. 红黑树管理 fd
3. 回调机制
效率极高。
十九、epoll 工作流程
创建 epoll
intepfd=epoll_create1(0);注册 fd
epoll_ctl(epfd,EPOLL_CTL_ADD,fd,&ev);等待事件
epoll_wait(epfd,events,max_events,-1);非阻塞 IO
配合:
fcntl(fd,F_SETFL,O_NONBLOCK);真正实现:
单线程高并发二十、总结
整个 FTP Server 的演进过程:
TCP Server ↓ 多线程并发 ↓ FTP 命令解析 ↓ PASV 数据连接 ↓ LIST / RETR / STOR ↓ 路径系统 ↓ 身份认证 ↓ sendfile 零拷贝 ↓ 断点续传 ↓ 线程池 ↓ epoll 高并发