文章目录
-
- [TCP Socket编程实战(一):API详解与单连接Echo Server](#TCP Socket编程实战(一):API详解与单连接Echo Server)
- 一、TCP和UDP的本质区别
-
- [1.1 为什么要学TCP](#1.1 为什么要学TCP)
- [1.2 核心区别对比](#1.2 核心区别对比)
- [1.3 面向连接是什么意思](#1.3 面向连接是什么意思)
- [1.4 字节流是什么意思](#1.4 字节流是什么意思)
- [二、TCP Socket API详解](#二、TCP Socket API详解)
-
- [2.1 socket():创建套接字](#2.1 socket():创建套接字)
- [2.2 bind():绑定地址](#2.2 bind():绑定地址)
- [2.3 listen():设置监听状态](#2.3 listen():设置监听状态)
-
- [2.3.1 为什么需要listen](#2.3.1 为什么需要listen)
- [2.3.2 backlog参数是什么](#2.3.2 backlog参数是什么)
- [2.4 accept():接受连接](#2.4 accept():接受连接)
-
- [2.4.1 参数详解](#2.4.1 参数详解)
- [2.4.2 两个fd的关系](#2.4.2 两个fd的关系)
- [2.4.3 使用示例](#2.4.3 使用示例)
- [2.4.4 阻塞特性](#2.4.4 阻塞特性)
- [2.5 connect():发起连接](#2.5 connect():发起连接)
-
- [2.5.1 参数详解](#2.5.1 参数详解)
- [2.5.2 自动bind的时机](#2.5.2 自动bind的时机)
- [2.5.3 使用示例](#2.5.3 使用示例)
- [三、V1 Echo Server完整实现](#三、V1 Echo Server完整实现)
-
- [3.1 项目结构](#3.1 项目结构)
- [3.2 Comm.hpp:公共定义](#3.2 Comm.hpp:公共定义)
- [3.3 TcpServer类的结构](#3.3 TcpServer类的结构)
- [3.4 Init():初始化三步曲](#3.4 Init():初始化三步曲)
-
- [3.4.1 setsockopt的作用](#3.4.1 setsockopt的作用)
- [3.5 Service():处理单个连接](#3.5 Service():处理单个连接)
-
- [3.5.1 read返回值的三种情况](#3.5.1 read返回值的三种情况)
- [3.5.2 为什么不用recv/send](#3.5.2 为什么不用recv/send)
- [3.6 Start():主循环](#3.6 Start():主循环)
- 四、TCP客户端实现
-
- [4.1 完整代码](#4.1 完整代码)
- [4.2 和UDP客户端的区别](#4.2 和UDP客户端的区别)
- 五、测试与问题验证
-
- [5.1 正常测试](#5.1 正常测试)
- [5.2 多客户端测试](#5.2 多客户端测试)
- 六、本篇总结
-
- [6.1 核心要点](#6.1 核心要点)
- [6.2 容易混淆的点](#6.2 容易混淆的点)
TCP Socket编程实战(一):API详解与单连接Echo Server
💬 开篇:前面四篇把UDP编程从基础到进阶全部讲完了。UDP是无连接的、不可靠的数据报协议,写起来简单直接。但真实的网络服务,绝大部分用的是TCP------面向连接、可靠传输、流式协议。这一篇开始TCP编程系列,第一篇先把TCP和UDP的本质区别讲清楚,然后逐个拆解TCP的核心API(socket/bind/listen/accept/connect),最后实现一个单连接的Echo Server。理解了这些基础,后面的多进程、多线程、线程池版本才能看懂为什么要这样设计。
👍 点赞、收藏与分享:这篇会逐行拆解TCP服务器和客户端的代码,把每个API的参数、返回值、使用场景都讲清楚。如果对你有帮助,请点赞收藏!
🚀 循序渐进:从TCP和UDP的区别开始,到API详解,到完整代码实现,到测试验证,一步步把TCP编程的基础打稳。
一、TCP和UDP的本质区别
1.1 为什么要学TCP
UDP编程我们已经很熟练了:创建socket → bind端口 → recvfrom/sendto,没有连接的概念,每次收发都要指定对方地址。
但UDP有个致命问题:不可靠。数据包可能丢失、乱序、重复,UDP协议本身不管这些问题。如果你要传输重要数据(比如文件、数据库记录、支付信息),用UDP就得自己实现重传、去重、排序,非常麻烦。
TCP解决了这些问题。它在传输层提供了可靠的、有序的、面向连接的字节流服务。绝大部分应用层协议(HTTP、FTP、SMTP、SSH)都基于TCP。
1.2 核心区别对比
| 特性 | UDP | TCP |
|---|---|---|
| 连接 | 无连接 | 面向连接(三次握手建立,四次挥手关闭) |
| 可靠性 | 不可靠(丢包、乱序、重复) | 可靠(确认重传、顺序保证、去重) |
| 数据边界 | 保留边界(每个数据报独立) | 无边界(字节流,可能粘包) |
| 速度 | 快(无握手、无确认) | 慢(握手开销、确认重传) |
| 适用场景 | 实时性要求高(视频、游戏) | 可靠性要求高(文件传输、网页) |
1.3 面向连接是什么意思
UDP的工作方式:
bash
客户端:sendto(数据, 服务器地址) → 数据包飞出去
服务器:recvfrom(...) → 收到数据包
没有连接的概念,发完就完了,不管对方收没收到。
TCP的工作方式:
bash
客户端:connect(服务器地址) → 发起连接请求
服务器:accept() → 接受连接
【此时建立了一条虚拟的"连接"】
客户端:write(数据) → 通过这条连接发送
服务器:read(...) → 通过这条连接接收
连接建立后,双方可以持续通信,不用每次都指定对方地址。连接是有状态的,TCP协议栈会维护连接的各种信息(序列号、窗口大小、定时器等)。
1.4 字节流是什么意思
UDP是数据报协议 :发送方调用一次sendto发送100字节,接收方调用recvfrom就能收到完整的100字节,边界清晰。
TCP是字节流协议 :发送方连续调用两次write,每次发送100字节,接收方调用一次read可能收到200字节(两次的数据粘在一起了),也可能只收到50字节(一次的数据被拆开了)。TCP不保证数据边界,只保证字节顺序。
这就是粘包问题,后面的文章会专门讲怎么解决。
二、TCP Socket API详解
2.1 socket():创建套接字
c
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
这个函数UDP和TCP都用,但type参数不同:
| 参数 | UDP | TCP |
|---|---|---|
| domain | AF_INET |
AF_INET |
| type | SOCK_DGRAM |
SOCK_STREAM |
| protocol | 0 |
0 |
SOCK_STREAM表示流式传输,对应TCP协议。
返回值 :成功返回文件描述符(非负整数),失败返回-1并设置errno。
使用示例:
c
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("socket error");
exit(1);
}
2.2 bind():绑定地址
c
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
和UDP完全一样,把socket和本地地址(IP+端口)绑定。
服务器必须bind,因为客户端要知道服务器的固定端口才能连接。
客户端可以不bind ,系统会在connect时自动分配端口。
返回值:成功返回0,失败返回-1。
使用示例:
c
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) != 0) {
perror("bind error");
exit(1);
}
2.3 listen():设置监听状态
c
int listen(int sockfd, int backlog);
这是TCP特有的,UDP没有这个函数。
2.3.1 为什么需要listen
TCP是面向连接的,服务器要先进入"监听状态",等待客户端发起连接请求。listen就是告诉内核:"这个socket准备好接受连接了,客户端可以来连我了"。
2.3.2 backlog参数是什么
backlog指定已完成连接但尚未accept的队列长度。
TCP三次握手完成后,连接就建立了,但服务器可能还没来得及调用accept。这些已建立的连接会放在一个队列里,backlog就是这个队列的最大长度。
bash
客户端1 → 三次握手完成 → 放入队列(等待accept)
客户端2 → 三次握手完成 → 放入队列
客户端3 → 三次握手完成 → 放入队列
...
【队列满了】
客户端N → 三次握手 → 服务器拒绝(或者客户端超时)
一般设置为5或者6就够了。设太大也没意义,因为accept通常很快就会被调用。
返回值:成功返回0,失败返回-1。
使用示例:
c
if (listen(sockfd, 6) != 0) {
perror("listen error");
exit(1);
}
2.4 accept():接受连接
c
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
这是服务器端专用的,用来从已完成连接的队列中取出一个连接。
2.4.1 参数详解
| 参数 | 含义 |
|---|---|
| sockfd | 监听socket的fd(就是bind和listen时用的那个) |
| addr | 传出参数,返回客户端的地址信息 |
| addrlen | 传入传出参数,传入缓冲区大小,传出实际地址长度 |
返回值 :成功返回新的socket fd,失败返回-1。
2.4.2 两个fd的关系
这是TCP编程最容易混淆的地方:为什么accept返回一个新的fd?
bash
监听fd(listensock):
- 用来listen和accept
- 一直存在,不关闭
- 不用来读写数据
连接fd(sockfd):
- accept返回的新fd
- 用来和客户端通信(read/write)
- 通信结束后关闭
打个比方:监听fd就像饭店的前台,负责接待客人(accept)。每来一个客人,就分配一张桌子(连接fd),客人在这张桌子上点菜吃饭(read/write)。前台不参与具体服务,只负责接待新客人。
2.4.3 使用示例
c
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(listensock, (struct sockaddr*)&peer, &len);
if (sockfd < 0) {
perror("accept error");
return;
}
// 打印客户端信息
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
uint16_t port = ntohs(peer.sin_port);
printf("New connection from %s:%d\n", ip, port);
2.4.4 阻塞特性
如果没有客户端连接,accept会阻塞等待,直到有客户端连上来。
这和UDP的recvfrom类似,但区别是:
recvfrom阻塞等待数据包accept阻塞等待连接
2.5 connect():发起连接
c
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
这是客户端专用的,用来向服务器发起连接。
2.5.1 参数详解
| 参数 | 含义 |
|---|---|
| sockfd | 客户端的socket fd |
| addr | 服务器的地址(IP+端口) |
| addrlen | 地址结构体长度 |
返回值:成功返回0,失败返回-1。
2.5.2 自动bind的时机
客户端没有显式调用bind,那它的本地端口是什么时候确定的?
答案是:在connect成功的时候,系统自动给客户端分配端口并绑定。
c
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
// 此时sockfd还没有绑定端口
connect(sockfd, ...);
// connect内部:
// 1. 自动从动态端口范围(49152-65535)选一个空闲端口
// 2. 绑定到sockfd上
// 3. 发起三次握手
// 4. 握手成功后返回0
2.5.3 使用示例
c
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(8888);
inet_pton(AF_INET, "192.168.1.100", &server.sin_addr);
if (connect(sockfd, (struct sockaddr*)&server, sizeof(server)) != 0) {
perror("connect error");
exit(1);
}
printf("Connected to server\n");
三、V1 Echo Server完整实现
3.1 项目结构
bash
.
├── nocopy.hpp // 禁止拷贝基类
├── Log.hpp // 日志模块
├── Comm.hpp // 公共枚举和宏
├── TcpServer.hpp // TCP服务器核心
└── TcpClient.cc // TCP客户端
3.2 Comm.hpp:公共定义
cpp
#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
enum {
Usage_Err = 1,
Socket_Err,
Bind_Err,
Listen_Err
};
#define CONV(addr_ptr) ((struct sockaddr *)addr_ptr)
CONV宏用来做类型转换,把sockaddr_in*转成sockaddr*,避免每次都写一长串强制转换。
3.3 TcpServer类的结构
cpp
const static int default_backlog = 6;
class TcpServer : public nocopy
{
public:
TcpServer(uint16_t port) : _port(port), _isrunning(false)
{
}
void Init(); // 初始化:socket/bind/listen
void Start(); // 启动:accept循环
void Service(int sockfd); // 处理单个连接
private:
uint16_t _port;
int _listensock; // 监听fd
bool _isrunning;
};
3.4 Init():初始化三步曲
cpp
void Init()
{
// 第一步:创建socket
_listensock = socket(AF_INET, SOCK_STREAM, 0);
if (_listensock < 0) {
lg.LogMessage(Fatal, "create socket error, errno code: %d, error string: %s\n",
errno, strerror(errno));
exit(Socket_Err);
}
// 设置地址重用(后面会详细讲)
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt)); // 需要才开
lg.LogMessage(Debug, "create socket success, sockfd: %d\n", _listensock);
// 第二步:绑定地址
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(_listensock, CONV(&local), sizeof(local)) != 0) {
lg.LogMessage(Fatal, "bind socket error, errno code: %d, error string: %s\n",
errno, strerror(errno));
exit(Bind_Err);
}
lg.LogMessage(Debug, "bind socket success, sockfd: %d\n", _listensock);
// 第三步:设置监听状态
if (listen(_listensock, default_backlog) != 0) {
lg.LogMessage(Fatal, "listen socket error, errno code: %d, error string: %s\n",
errno, strerror(errno));
exit(Listen_Err);
}
lg.LogMessage(Debug, "listen socket success, sockfd: %d\n", _listensock);
}
3.4.1 setsockopt的作用
SO_REUSEADDR:允许重用处于TIME_WAIT状态的地址。
TCP连接关闭后,socket会进入TIME_WAIT状态(通常持续2分钟)。如果不设置这个选项,在TIME_WAIT期间重启服务器会报"Address already in use"错误。设置后可以立刻重启。
SO_REUSEPORT:允许多个socket绑定同一个端口。
Linux 3.9+支持,可以实现简单的负载均衡。但这里主要是为了配合SO_REUSEADDR使用。
这两个选项在开发阶段非常有用,避免每次测试都要等2分钟。生产环境也建议设置。
3.5 Service():处理单个连接
cpp
void Service(int sockfd)
{
char buffer[1024];
while (true) {
// 读取客户端数据
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0) {
buffer[n] = 0; // 加'\0'
std::cout << "client say# " << buffer << std::endl;
// 回显
std::string echo_string = "server echo# ";
echo_string += buffer;
write(sockfd, echo_string.c_str(), echo_string.size());
}
else if (n == 0) {
// 客户端关闭连接
lg.LogMessage(Info, "client quit...\n");
break;
}
else {
// 读取错误
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n",
errno, strerror(errno));
break;
}
}
}
3.5.1 read返回值的三种情况
返回值 > 0:成功读取了n字节数据。
返回值 == 0 :对端关闭了连接 。这是TCP的重要特性,当客户端调用close关闭连接时,服务器的read会返回0,表示文件结尾(EOF)。
返回值 < 0:发生错误。可能是网络断开、socket被关闭等。
这三种情况必须都处理,否则程序会出现各种奇怪的问题。
3.5.2 为什么不用recv/send
TCP也可以用recv/send代替read/write:
cpp
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
recv/send多了一个flags参数,可以设置MSG_DONTWAIT(非阻塞)、MSG_PEEK(查看数据但不移除)等选项。
但大部分情况下flags都是0,所以直接用read/write更简洁。UDP必须用recvfrom/sendto(需要指定地址),TCP可以用read/write。
3.6 Start():主循环
cpp
void Start()
{
_isrunning = true;
while (_isrunning) {
// 等待客户端连接
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensock, CONV(&peer), &len);
if (sockfd < 0) {
lg.LogMessage(Warning, "accept socket error, errno code: %d, error string: %s\n",
errno, strerror(errno));
continue; // 失败了继续等待下一个连接
}
lg.LogMessage(Debug, "accept success, get new sockfd: %d\n", sockfd);
// 处理这个连接
Service(sockfd);
// 关闭连接fd
close(sockfd);
}
}
流程很清晰:accept → 处理 → 关闭,循环往复。
但这里有个致命问题 :Service(sockfd)会一直循环读取这个客户端的数据,期间不会返回。这意味着如果第一个客户端一直不断开,服务器就永远不会再调用accept,第二个客户端根本连不上。
这就是V1的缺陷,后面的V2/V3/V4都是为了解决这个问题。
四、TCP客户端实现
4.1 完整代码
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Comm.hpp"
void Usage(const std::string &process)
{
std::cout << "Usage: " << process << " server_ip server_port" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3) {
Usage(argv[0]);
return 1;
}
std::string serverip = argv[1];
uint16_t serverport = std::stoi(argv[2]);
// 1. 创建socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
std::cerr << "socket error" << std::endl;
return 1;
}
// 2. 连接服务器(自动bind)
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);
if (connect(sockfd, CONV(&server), sizeof(server)) != 0) {
std::cerr << "connect error" << std::endl;
return 2;
}
// 3. 通信循环
while (true) {
std::string inbuffer;
std::cout << "Please Enter# ";
std::getline(std::cin, inbuffer);
// 发送
ssize_t n = write(sockfd, inbuffer.c_str(), inbuffer.size());
if (n <= 0) break;
// 接收
char buffer[1024];
ssize_t m = read(sockfd, buffer, sizeof(buffer) - 1);
if (m > 0) {
buffer[m] = 0;
std::cout << "get echo message -> " << buffer << std::endl;
}
else {
break;
}
}
close(sockfd);
return 0;
}
4.2 和UDP客户端的区别
UDP客户端:
cpp
sendto(sockfd, data, len, 0, (sockaddr*)&server, sizeof(server));
recvfrom(sockfd, buffer, len, 0, (sockaddr*)&temp, &len);
每次收发都要指定地址。
TCP客户端:
cpp
connect(sockfd, (sockaddr*)&server, sizeof(server)); // 一次连接
write(sockfd, data, len); // 后续通信不用指定地址
read(sockfd, buffer, len);
连接建立后,所有通信都在这条连接上进行,不需要每次指定地址。
五、测试与问题验证
5.1 正常测试
启动服务器:
bash
./tcp_server 8888
启动客户端:
bash
./tcp_client 127.0.0.1 8888
输入消息,服务器正确回显。
5.2 多客户端测试
再启动第二个客户端:
bash
./tcp_client 127.0.0.1 8888
发现第二个客户端能连上(connect成功),但发送消息后没有回复。
原因分析:
- 第一个客户端connect成功,服务器accept返回,进入
Service循环 Service一直在read等待第一个客户端的数据- 第二个客户端connect时,三次握手完成,连接放入accept队列
- 但服务器卡在
Service里,没有再次调用accept - 第二个客户端的连接fd一直在队列里,没被取出来,也就没有人读取它发送的数据
这就是单连接服务器的致命缺陷。
六、本篇总结
6.1 核心要点
TCP vs UDP:
- TCP面向连接、可靠传输、字节流协议
- UDP无连接、不可靠、数据报协议
- TCP有三次握手建立连接,四次挥手关闭连接
- TCP无数据边界,可能粘包
TCP API:
socket(AF_INET, SOCK_STREAM, 0):创建TCP socketbind(sockfd, addr, len):绑定地址(服务器必须,客户端可选)listen(sockfd, backlog):设置监听状态(TCP特有)accept(listensock, addr, len):接受连接,返回新的连接fdconnect(sockfd, addr, len):发起连接(客户端),自动bind
两个fd:
- 监听fd:用于listen和accept,不用于数据传输
- 连接fd:accept返回的新fd,用于read/write通信
read返回值:
> 0:成功读取== 0:对端关闭连接(EOF)< 0:发生错误
V1的问题:
- Service阻塞在单个连接的处理上
- 无法accept新连接
- 只能服务一个客户端
6.2 容易混淆的点
-
为什么accept返回新fd:监听fd负责接待,连接fd负责具体服务,职责分离。
-
客户端需不需要bind:不需要显式bind,connect时自动绑定动态端口。
-
backlog设多大:一般5-10,表示已完成连接但未accept的队列长度。
-
read返回0是什么意思:对端关闭连接,不是错误,是正常的EOF。
-
SO_REUSEADDR的作用:允许重用TIME_WAIT状态的地址,避免重启服务器报错。
-
为什么Service用while循环:TCP是长连接,一次连接可以多次收发,所以要循环。
💬 总结:这一篇把TCP的基础API和单连接服务器讲清楚了。从TCP和UDP的本质区别,到socket/bind/listen/accept/connect的详细用法,到完整的Echo Server实现,到测试验证V1的致命缺陷。下一篇会讲怎么用多进程解决这个问题,实现真正的并发服务器。
👍 点赞、收藏与分享:如果这篇帮你理解了TCP编程的基础,请点赞收藏!下一篇的多进程和多线程版本会更精彩!