目录
一、TCP vs UDP —— 一句话讲清区别
二、单进程 TCP 服务器完整拆解
2.1 socket + bind —— 跟 UDP 一样
2.2 setsockopt
2.3 listen —— 把电话线插上
2.4 两个文件描述符 —— 整篇文章最重要的概念
2.5 accept —— 前台叫号
2.6 单进程版的完整流程
2.7 ⭐ read 返回值 —— 最要命的坑
三、客户端实现 —— 拨号打电话
四、源码 · 单进程 TCP 服务器完整版
makefile
Log.hpp
TcpServer.hpp
Main.cc
TcpClient.cc
五、本篇总结
最近在啃 Linux 网络编程,这篇文章是这个系列的第一篇,从最基础的单进程 TCP 服务器讲起。
这篇主要讲三件事:
- 三个核心 API:listen、accept、read/write
- 一个核心概念:两个文件描述符 —— listensock_ 和 sockfd
- 一个要命的坑:read 返回 0 为什么不处理会炸
一、TCP vs UDP —— 一句话讲清区别
搞网络编程,第一步是搞清楚 TCP 和 UDP 到底有什么不一样。用生活类比是最容易理解的。
UDP 像寄信。你写好一封信,扔进邮筒,对方能不能收到、什么时候收到,你不知道也不关心。你可以同时给张三、李四、王五各寄一封,完全不冲突。
TCP 像打电话。你必须先拨号,对方接了,你们之间建立了一条专线。你在这头说,他在那头听。说完了挂电话,线路就断了。
落实到代码上,差别就在流程:
| 步骤 | UDP 服务器 | TCP 服务器 |
|---|---|---|
| 1 | socket() | socket() |
| 2 | bind() | bind() |
| 3 | — | listen() |
| 4 | — | accept() |
| 5 | recvfrom() / sendto() | read() / write() |
UDP 两步半就完事了,TCP 多了 listen 和 accept。这多出来的两个 API,就是 TCP 整个复杂度的源头。
二、单进程 TCP 服务器完整拆解
下面我拆开讲每个环节,配代码,配说明,争取你看完就能自己敲出来。
2.1 socket + bind —— 跟 UDP 一样
listensock_ = socket(AF_INET, SOCK_STREAM, 0);注意这里的SOCK_STREAM。UDP 用的是SOCK_DGRAM(数据报,一个个独立小包裹),TCP 用的是SOCK_STREAM(流式,像水管里的水,没有边界)。
bind 的部分和 UDP 完全一样:初始化一个struct sockaddr_in,填上 IP 和端口,传进去绑。
2.2 setsockopt
int opt = 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt));这行代码是防坑专用。
场景:你跑着服务器,发现有个 bug,ctrl+c 停了,马上改完代码重新跑。结果报错:bind error: Address already in use。
原因:操作系统在端口释放后有一个TIME_WAIT状态(几十秒到几分钟不等)。这段时间内端口被认为"还在使用中",不让绑定。
解法:SO_REUSEADDR|SO_REUSEPORT告诉内核:"哥们我知道端口还没完全释放,但我赶时间,让我先用。"
建议:开发阶段必须加。不加的话每次重启都要等几十秒
2.3 listen —— 把电话线插上
listen(listensock_, backlog); // backlog = 10listen 做了什么?它把 socket 创建的 listensock_ 变成了监听状态,告诉操作系统:"这个 socket 可以接收客户端的连接请求了。"
backlog 是内核中连接等待队列的长度。想像一下:前台接待只能同时记住 10 个在门口排队的客人。第 11 个人来了,就得等前面有人被叫进去了才能登记。
backlog 一般设 10 左右,不用设太大。
2.4 两个文件描述符 —— 整篇文章最重要的概念
先看一段初始化代码:
class TcpServer { int listensock_; // 由 socket() 创建 uint16_t port_; std::string ip_; };这里有个命名上的细节。在 UDP 服务器里我们管 socket 的返回值叫sockfd。但在 TCP 服务器里,作者把它命名为listensock_。
为什么?
因为 TCP 服务器有两个文件描述符,各司其职。
| 变量 | 谁创建的 | 作用 | 数量 |
|---|---|---|---|
listensock_ | socket() | 只负责监听连接请求 | 1 个 |
sockfd | accept() 的返回值 | 负责与客户端读写通信 | 多个(每个连接一个) |
用饭店类比:
- listensock_ = 前台接待。看见客人来了,喊一声"服务员,3 号桌有客人"。
- sockfd = 专属服务员。走过来:"您好,想吃点什么?"然后一对一服务。
listensock_ 不负责跟任何客人聊天,它就坐在前台监工。只有 accept 返回的 sockfd 才负责实际的读写通信。
常见错误:新手拿到 listensock_ 直接去 read/write,发现读不到数据。那是肯定的——listensock_ 是前台接待,不是服务员。
2.5 accept —— 前台叫号
struct sockaddr_in client; socklen_t len = sizeof(client); int sockfd = accept(listensock_, (struct sockaddr*)&client, &len);accept 干了三件事:
- 从内核的连接等待队列中取出一个已经完成三次握手的连接
- 创建一个新的文件描述符sockfd,专门用于和这个客户端通信
- 把客户端的 IP、端口等信息填到 client 结构体里
如果连接成功,accept 返回一个大于 0 的 sockfd。
如果连接失败(极少见),返回 -1。
拿到 sockfd 后,可以用inet_ntop把 IP 地址转成字符串,用ntohs把端口号转成主机字节序:
uint16_t clientport = ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); lg(Info, "get a new link, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport);2.6 单进程版的完整流程
void StartServer() { lg(Info, "tcpserver is running..."); for (;;) { // 1. 等一个客户来 int sockfd = accept(listensock_, ...); if (sockfd < 0) { continue; // 连接失败就继续等 } // 2. 解析客户端信息(IP + 端口) // 3. 一对一服务 Service(sockfd, clientip, clientport); // 4. 服务完关闭 close(sockfd); } }这就是单进程/单线程的全部秘密。
来了一个客户 → accept 领到桌 → Service 开始服务 → 服务完 close → 回循环顶部继续等下一个。
在服务 A 的整个过程中,B、C、D 来了,就在门口排队等着。
2.7 ⭐ read 返回值 —— 最要命的坑
void Service(int sockfd, const std::string& clientip, uint16_t clientport) { while (true) { char inbuffer[4096]; ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n > 0) { // 正常收到数据 inbuffer[n] = 0; // 手动补 '\0' // 处理并回复 write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n == 0) { // 🔥 客户端退出了! lg(Info, "%s:%d quit", clientip.c_str(), clientport); break; } else { // 读取出错 lg(Warning, "read error"); break; } } }为什么 n == 0 必须处理?
我用管道类比来解释。
你和朋友之间用一根水管通话。你在水管这头(服务器),他在那头(客户端)。
- 正常情况:朋友往水管里倒水(write),你用杯子接(read)
- 朋友走了:朋友把水管那头关了(关闭写端)
- 你还举着杯子等:水管那头堵死了,你等一整天也等不到水
这时候操作系统会怎么做?
它发现你在做一个永远读不到数据的 read,认为你在浪费 CPU。它会直接把你的服务器进程 kill 掉。
你试想一下:一个客户端正常退出,结果整个服务器被操作系统杀了。所有其他正在通信的客户端全部掉线。如果你的服务器是微信服务器,那就是几亿人同时掉线。
所以要主动处理 n == 0,break 退出,close(sockfd),告诉操作系统"我知道了"。操作系统看你主动关了,就不杀你了。
三、客户端实现 —— 拨号打电话
客户端的逻辑比服务器简单得多:
int sockfd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in server; server.sin_family = AF_INET; server.sin_port = htons(serverport); inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr)); int n = connect(sockfd, (struct sockaddr*)&server, len);connect —— 拨号。就像你拿起电话(sockfd),输入对方的号码(IP+端口),按拨号键。
客户端要不要 bind?
答案是:要,但你不用手动写 bind。操作系统在 connect 的时候自动给你随机分配了一个端口号。
你打开微信的时候,不需要关心"微信用哪个端口连的服务器",对吧?那是操作系统的事。同理,我们自己写的客户端也不用手动 bind。
四、源码 · 单进程 TCP 服务器完整版
makefile
all:tcpserver tcpclient tcpserver:Main.cc g++ -o $@ $^ -std=c++11 tcpclient:TcpClient.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f tcpserver tcpclientLog.hpp
#pragma once #include <iostream> #include <string> #include <ctime> #include <cstdio> #include <cstdarg> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #define SIZE 1024 #define Info 0 #define Debug 1 #define Warning 2 #define Error 3 #define Fatal 4 #define Screen 1 #define Onefile 2 #define Classfile 3 #define LogFile "log.txt" class Log { public: Log() { printMethod = Screen; path = "./log/"; } void Enable(int method) { printMethod = method; } ~Log() {} std::string levelToString(int level) { switch(level) { case Info: return "Info"; case Debug: return "Debug"; case Warning: return "Warning"; case Error: return "Error"; case Fatal: return "Fatal"; default: return ""; } } void operator()(int level, const char* format, ...) { time_t t = time(nullptr); struct tm* ctime = localtime(&t); char leftbuffer[SIZE]; snprintf(leftbuffer, sizeof(leftbuffer), "[%s][%d-%d-%d %d:%d:%d]", levelToString(level).c_str(), ctime->tm_year + 1900, ctime->tm_mon + 1, ctime->tm_mday, ctime->tm_hour, ctime->tm_min, ctime->tm_sec); va_list s; va_start(s, format); char rightbuffer[SIZE]; vsnprintf(rightbuffer, sizeof(rightbuffer), format, s); va_end(s); char logtxt[2 * SIZE]; snprintf(logtxt, sizeof(logtxt), "%s %s", leftbuffer, rightbuffer); printLog(level, logtxt); } void printLog(int level, const std::string& logtxt) { switch(printMethod) { case Screen: std::cout << logtxt << std::endl; break; case Onefile: printOneFile(LogFile, logtxt); break; case Classfile: printClassFile(level, logtxt); break; default: break; } } void printOneFile(const std::string& logname, const std::string& logtxt) { std::string _logname = path + logname; int fd = open(_logname.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666); if(fd < 0) return; write(fd, logtxt.c_str(), logtxt.size()); close(fd); } void printClassFile(int level, const std::string& logtxt) { std::string filename = LogFile; filename += "."; filename += levelToString(level); printOneFile(filename, logtxt); } private: int printMethod; std::string path; }; Log lg;TcpServer.hpp
#include <iostream> #include <string> #include <cstring> #include <unistd.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include "Log.hpp" const int defaultfd = -1; const uint16_t defaultport = 8080; const std::string defaultip = "0.0.0.0"; const int backlog = 10; extern Log lg; enum { UsageError = 1, SocketError, BindError, ListenError }; class TcpServer { public: TcpServer(const uint16_t &port = defaultport, const std::string &ip = defaultip) : listensock_(defaultfd), port_(port), ip_(ip) {} void InitServer() { listensock_ = socket(AF_INET, SOCK_STREAM, 0); if (listensock_ < 0) { lg(Fatal, "create socket error"); exit(SocketError); } lg(Info, "create socket success, listensock_: %d", listensock_); int opt = 1; setsockopt(listensock_, SOL_SOCKET, SO_REUSEADDR|SO_REUSEPORT, &opt, sizeof(opt)); struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(port_); inet_aton(ip_.c_str(), &(server.sin_addr)); socklen_t len = sizeof(server); if (bind(listensock_, (struct sockaddr *)&server, len) < 0) { lg(Fatal, "bind error"); exit(BindError); } lg(Info, "bind socket success, listensock_: %d", listensock_); if (listen(listensock_, backlog) < 0) { lg(Fatal, "listen error"); exit(ListenError); } lg(Info, "listen socket success, listensock_: %d", listensock_); } void StartServer() { lg(Info, "tcpserver is running..."); for (;;) { struct sockaddr_in client; socklen_t len = sizeof(client); int sockfd = accept(listensock_, (struct sockaddr*)&client, &len); if (sockfd < 0) { lg(Warning, "accept error"); continue; } uint16_t clientport = ntohs(client.sin_port); char clientip[32]; inet_ntop(AF_INET, &(client.sin_addr), clientip, sizeof(clientip)); lg(Info, "get a new link, sockfd: %d, client ip: %s, client port: %d", sockfd, clientip, clientport); Service(sockfd, clientip, clientport); close(sockfd); } } void Service(int sockfd, const std::string& clientip, uint16_t clientport) { while (true) { char inbuffer[4096]; ssize_t n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n > 0) { inbuffer[n] = 0; std::cout << "client say# " << inbuffer << std::endl; std::string echo_string = "tcpserver echo# "; echo_string += inbuffer; write(sockfd, echo_string.c_str(), echo_string.size()); } else if (n == 0) { lg(Info, "%s:%d quit, server close sockfd: %d", clientip.c_str(), clientport, sockfd); break; } else { lg(Warning, "read error, sockfd: %d", sockfd); break; } } } ~TcpServer() { if (listensock_ > 0) close(listensock_); } private: int listensock_; uint16_t port_; std::string ip_; };Main.cc
#include <iostream> #include <memory> #include "TcpServer.hpp" void Usage(const std::string str) { std::cout << "\n\tUsage: " << str << " port[1024+]\n" << std::endl; } int main(int argc, char* argv[]) { if (argc != 2) { Usage(argv[0]); exit(UsageError); } uint16_t port = std::stoi(argv[1]); std::unique_ptr<TcpServer> server(new TcpServer(port)); server->InitServer(); server->StartServer(); return 0; }TcpClient.cc
#include <iostream> #include <string> #include <unistd.h> #include <cstring> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> void Usage(const std::string& str) { std::cout << "\n\tUsage: " << str << " serverip serverport\n" << std::endl; } int main(int argc, char* argv[]) { if (argc != 3) { Usage(argv[0]); return 0; } std::string serverip = argv[1]; uint16_t serverport = std::stoi(argv[2]); int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { std::cerr << "socket create err" << std::endl; return 1; } struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverport); inet_pton(AF_INET, serverip.c_str(), &(server.sin_addr)); socklen_t len = sizeof(server); int n = connect(sockfd, (struct sockaddr*)&server, len); if (n < 0) { std::cerr << "connect err..." << std::endl; return 2; } std::string message; char inbuffer[4096]; while (true) { std::cout << "Please Enter# "; std::getline(std::cin, message); n = write(sockfd, message.c_str(), message.size()); if (n < 0) { std::cerr << "write err" << std::endl; break; } n = read(sockfd, inbuffer, sizeof(inbuffer) - 1); if (n > 0) { inbuffer[n] = 0; std::cout << inbuffer << std::endl; } else { break; } } close(sockfd); return 0; }五、本篇总结
这篇我们做了三件事:
- 理解了 TCP 的三个核心 API:listen(插电话线)、accept(叫号)、read/write(对话)
- 分清了两个文件描述符:listensock_(前台接待)只负责监听,sockfd(专属服务员)负责通信
- 搞懂了一个最要命的坑:read 返回 0 必须处理,否则操作系统会杀进程
但也暴露了一个大问题:单进程版一次只能服务一个客户端,后面的排队等到死。
下一篇,我将尝试多进程、多线程、线程池等并发方案,让服务器真正能同时服务多个客户端。