目录
[一、TCP vs UDP ------ 一句话讲清区别](#一、TCP vs UDP —— 一句话讲清区别)
[二、单进程 TCP 服务器完整拆解](#二、单进程 TCP 服务器完整拆解)
[2.1 socket + bind ------ 跟 UDP 一样](#2.1 socket + bind —— 跟 UDP 一样)
[2.2 setsockopt](#2.2 setsockopt)
[2.3 listen ------ 把电话线插上](#2.3 listen —— 把电话线插上)
[2.4 两个文件描述符 ------ 整篇文章最重要的概念](#2.4 两个文件描述符 —— 整篇文章最重要的概念)
[2.5 accept ------ 前台叫号](#2.5 accept —— 前台叫号)
[2.6 单进程版的完整流程](#2.6 单进程版的完整流程)
[2.7 ⭐ read 返回值 ------ 最要命的坑](#2.7 ⭐ read 返回值 —— 最要命的坑)
[三、客户端实现 ------ 拨号打电话](#三、客户端实现 —— 拨号打电话)
[四、源码 · 单进程 TCP 服务器完整版](#四、源码 · 单进程 TCP 服务器完整版)

最近在啃 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 一样
cpp
listensock_ = socket(AF_INET, SOCK_STREAM, 0);
注意这里的 SOCK_STREAM。UDP 用的是 SOCK_DGRAM(数据报,一个个独立小包裹),TCP 用的是 SOCK_STREAM(流式,像水管里的水,没有边界)。
bind 的部分和 UDP 完全一样:初始化一个 struct sockaddr_in,填上 IP 和端口,传进去绑。
2.2 setsockopt
cpp
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 ------ 把电话线插上
cpp
listen(listensock_, backlog); // backlog = 10
listen 做了什么?它把 socket 创建的 listensock_ 变成了监听状态,告诉操作系统:"这个 socket 可以接收客户端的连接请求了。"
backlog 是内核中连接等待队列的长度。想像一下:前台接待只能同时记住 10 个在门口排队的客人。第 11 个人来了,就得等前面有人被叫进去了才能登记。
backlog 一般设 10 左右,不用设太大。
2.4 两个文件描述符 ------ 整篇文章最重要的概念
先看一段初始化代码:
cpp
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 ------ 前台叫号
cpp
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 把端口号转成主机字节序:
cpp
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 单进程版的完整流程
cpp
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 返回值 ------ 最要命的坑
cpp
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),告诉操作系统"我知道了"。操作系统看你主动关了,就不杀你了。
三、客户端实现 ------ 拨号打电话
客户端的逻辑比服务器简单得多:
cpp
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
cpp
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 tcpclient
Log.hpp
cpp
#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
cpp
#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
cpp
#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
cpp
#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 必须处理,否则操作系统会杀进程
但也暴露了一个大问题:单进程版一次只能服务一个客户端,后面的排队等到死。
下一篇,我将尝试多进程、多线程、线程池等并发方案,让服务器真正能同时服务多个客户端。