目录
- 基础准备:环境与工具
- 开发环境
- 必备工具
- 核心概念:OSI 七层模型与 TCP/IP 协议栈
- 网络分层思想
- TCP/IP 协议栈详解
- Socket:应用程序与网络的接口
- 入门篇:使用 Socket API 进行 TCP 编程
- TCP 特点回顾
- 编写第一个 TCP 服务器
- 编写第一个 TCP 客户端
- 完整代码示例与分析
- 编译与运行
- 进阶篇:使用 Socket API 进行 UDP 编程
- UDP 特点回顾
- UDP 服务器与客户端代码示例
- TCP vs. UDP
- 深入篇:I/O 模型与并发编程
- 阻塞 I/O (Blocking I/O)
- 非阻塞 I/O (Non-blocking I/O)
- I/O 多路复用
selectpollepoll(Linux 高性能网络的核心)
- 多线程/多进程模型
- 实战项目:简单聊天室
- 需求分析
- 设计思路
- 代码实现
- 高级主题与最佳实践
- 原子操作与锁
setsockopt与getsockopt- 优雅关闭
- 调试技巧 (
strace,netstat,tcpdump)
- 推荐资源
基础准备:环境与工具
开发环境
- 操作系统: Linux (推荐 Ubuntu, CentOS, 或其他发行版),Windows 下的 WSL 2 也是一个不错的选择。
- 编程语言: C 语言 (网络编程的“母语”,最底层,最核心)。
- 编译器: GCC (GNU Compiler Collection)。
必备工具
-
GCC: 用于编译 C 代码。
(图片来源网络,侵删)# Ubuntu/Debian sudo apt-get update sudo apt-get install build-essential # CentOS/RHEL sudo yum groupinstall "Development Tools"
-
文本编辑器: Vim, VS Code, Sublime Text 等,VS Code 配合 C/C++ 插件是现代且高效的选择。
-
调试工具: GDB (GNU Debugger)。
-
网络工具:
netstat,ss,tcpdump,nc(netcat)。
核心概念:OSI 七层模型与 TCP/IP 协议栈
在写代码之前,理解网络通信的基本原理至关重要。

网络分层思想
为了管理复杂的网络通信,人们设计了分层的模型,每一层都建立在下一层之上,专注于自己的任务,这样,每一层的改变都不会影响到其他层。
TCP/IP 协议栈
这是当今互联网事实上的标准模型,它将功能分为了四层:
| TCP/IP 四层模型 | 对应 OSI 七层模型 | 主要协议/功能 | 说明 |
|---|---|---|---|
| 应用层 | 应用层 | HTTP, FTP, SMTP, DNS | 面向用户的应用程序,提供特定的网络服务。 |
| 传输层 | 传输层 | TCP, UDP | 提供端到端的数据传输服务,TCP 可靠,UDP 不可靠但快速。 |
| 网络层 | 网络层 | IP, ICMP, ARP | 负责数据包的路由和转发,确保数据从源主机到达目标主机。 |
| 网络接口层 | 数据链路层 & 物理层 | Ethernet, Wi-Fi | 负责在同一个局域网内设备间的数据传输。 |
Socket:应用程序与网络的接口
Socket (套接字) 是操作系统提供给应用程序进行网络通信的 API (Application Programming Interface),你可以把它想象成一个“插座”,应用程序通过这个插座,可以“插上”网络线,收发数据。
在 Linux 中,一切皆文件,Socket 在编程中被视为一个 文件描述符,我们可以使用标准的 read(), write() 函数来对它进行读写操作,这大大简化了编程模型。

入门篇:使用 Socket API 进行 TCP 编程
TCP (Transmission Control Protocol) 是一种面向连接的、可靠的、基于字节流的传输层协议,它的特点是“三次握手”建立连接,“四次挥手”断开连接,并保证数据无差错、不丢失、不重复且按序到达。
编写第一个 TCP 服务器
服务器的工作流程通常是:
- 创建套接字
- 绑定地址和端口 (Bind)
- 监听连接 (Listen)
- 接受连接 (Accept) - 阻塞等待客户端连接
- 与客户端通信 (Read/Write)
- 关闭连接 (Close)
代码示例:tcp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int server_fd, new_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
char buffer[BUFFER_SIZE] = {0};
// 1. 创建套接字 (AF_INET for IPv4, SOCK_STREAM for TCP, 0 for IP)
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置套接字选项,允许地址重用
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 监听所有可用的网络接口
address.sin_port = htons(PORT); // 将端口号从主机字节序转换为网络字节序
// 2. 绑定地址和端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 3. 开始监听,最大连接数为 3
if (listen(server_fd, 3) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 4. 接受一个新连接
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept");
exit(EXIT_FAILURE);
}
// 5. 与客户端通信
int valread = read(new_socket, buffer, BUFFER_SIZE);
printf("Message from client: %s\n", buffer);
send(new_socket, "Hello from server", strlen("Hello from server"), 0);
printf("Hello message sent\n");
// 6. 关闭套接字
close(new_socket);
close(server_fd);
return 0;
}
编写第一个 TCP 客户端
客户端的工作流程相对简单:
- 创建套接字
- 连接服务器 (Connect)
- 与服务器通信 (Read/Write)
- 关闭连接 (Close)
代码示例:tcp_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8080
#define BUFFER_SIZE 1024
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
char *hello = "Hello from client";
char buffer[BUFFER_SIZE] = {0};
// 1. 创建套接字
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
printf("\n Socket creation error \n");
return -1;
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(PORT);
// 将 IPv4 地址从文本转换为二进制形式
if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {
printf("\nInvalid address/ Address not supported \n");
return -1;
}
// 2. 连接服务器
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
printf("\nConnection Failed \n");
return -1;
}
// 3. 发送数据
send(sock, hello, strlen(hello), 0);
printf("Hello message sent\n");
// 4. 接收数据
int valread = read(sock, buffer, BUFFER_SIZE);
printf("Server message: %s\n", buffer);
// 5. 关闭套接字
close(sock);
return 0;
}
编译与运行
- 保存文件: 将上面的代码分别保存为
tcp_server.c和tcp_client.c。 - 编译:
gcc tcp_server.c -o tcp_server gcc tcp_client.c -o tcp_client
- 运行:
- 首先在终端运行服务器:
./tcp_server
你会看到 "Server listening on port 8080..."。
- 然后在另一个新终端运行客户端:
./tcp_client
- 观察输出:
- 客户端终端会显示:
Hello message sent Server message: Hello from server - 服务器终端会显示:
Server listening on port 8080... Message from client: Hello from client Hello message sent
- 客户端终端会显示:
- 首先在终端运行服务器:
进阶篇:使用 Socket API 进行 UDP 编程
UDP (User Datagram Protocol) 是一种无连接的、不可靠的、数据报式的传输层协议,它不保证数据包的顺序或是否到达,但开销小,传输快。
UDP 服务器与客户端代码示例
UDP 的编程模型比 TCP 简单,没有 listen() 和 accept() 的概念,客户端和服务器是对等的,都可以使用 sendto() 和 recvfrom() 来收发数据。
代码示例:udp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8081
#define BUFFER_SIZE 1024
int main() {
int sock;
char buffer[BUFFER_SIZE];
struct sockaddr_in servaddr, cliaddr;
// 1. 创建套接字 (SOCK_DGRAM for UDP)
if ((sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = INADDR_ANY;
servaddr.sin_port = htons(PORT);
// 2. 绑定地址和端口
if (bind(sock, (const struct sockaddr *)&servaddr, sizeof(servaddr)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
printf("UDP Server listening on port %d...\n", PORT);
int len, n;
len = sizeof(cliaddr);
// 3. 循环接收数据
while (1) {
n = recvfrom(sock, (char *)buffer, BUFFER_SIZE, MSG_WAITALL, (struct sockaddr *)&cliaddr, &len);
buffer[n] = '\0';
printf("Client : %s\n", buffer);
sendto(sock, (const char *)"Hello from UDP server", strlen("Hello from UDP server"), MSG_CONFIRM, (const struct sockaddr *)&cliaddr, len);
printf("Hello message sent.\n");
}
close(sock);
return 0;
}
代码示例:udp_client.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8081
#define BUFFER_SIZE 1024
int main() {
int sock;
char *hello = "Hello from UDP client";
char buffer[BUFFER_SIZE];
struct sockaddr_in servaddr;
// 1. 创建套接字
if ((sock = socket(AF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket creation failed");
exit(EXIT_FAILURE);
}
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);
if (inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr) <= 0) {
perror("inet_pton failed");
exit(EXIT_FAILURE);
}
// 2. 发送数据
sendto(sock, (const char *)hello, strlen(hello), MSG_CONFIRM, (const struct sockaddr *)&servaddr, sizeof(servaddr));
printf("Hello message sent.\n");
// 3. 接收数据
int len;
socklen_t servaddr_len = sizeof(servaddr);
int n = recvfrom(sock, (char *)buffer, BUFFER_SIZE, MSG_WAITALL, (struct sockaddr *)&servaddr, &servaddr_len);
buffer[n] = '\0';
printf("Server : %s\n", buffer);
close(sock);
return 0;
}
TCP vs. UDP
| 特性 | TCP (传输控制协议) | UDP (用户数据报协议) |
|---|---|---|
| 连接性 | 面向连接 | 无连接 |
| 可靠性 | 可靠 (保证数据顺序、无丢失) | 不可靠 (不保证顺序和送达) |
| 速度 | 较慢 | 较快 |
| 开销 | 较高 (头部20字节,有握手挥手) | 较低 (头部8字节) |
| 应用场景 | Web浏览, 文件传输, 邮件 | 视频会议, 在线游戏, DNS查询 |
深入篇:I/O 模型与并发编程
一个简单的 TCP 服务器一次只能处理一个客户端连接,当它正在与客户端 A 通信时,客户端 B 的连接请求只能排队等待,为了同时处理多个客户端,我们需要引入并发编程。
阻塞 I/O (Blocking I/O)
这是我们前面例子中使用的默认模式。
accept(): 如果没有连接请求,进程会阻塞(暂停执行)。read(): 如果没有数据可读,进程会阻塞。- 缺点: 效率极低,一个进程在同一时间只能处理一个 I/O 操作。
非阻塞 I/O (Non-blocking I/O)
通过设置套接字为非阻塞模式,当 I/O 操作无法立即完成时,函数会立即返回一个错误码(如 EAGAIN 或 EWOULDBLOCK),而不是阻塞进程。
- 缺点: 需要不断地轮询(循环调用 I/O 函数)来检查数据是否准备好,这会消耗大量 CPU 资源,效率依然不高。
I/O 多路复用
这是解决高并发的关键,它允许单个进程同时监视多个 I/O 流(多个套接字),并在其中任何一个“就绪”(可读、可写或出现异常)时通知进程。
select
- 原理: 创建一个文件描述符集合(
fd_set),通过select()函数将这个集合交给内核,内核会检查这些 fd 的状态,当有 fd 就绪时,select()返回,并修改fd_set以指示哪些 fd 就绪了。 - 缺点:
- 数量限制:
fd_set的大小是固定的(通常是 1024),限制了能监视的 fd 数量。 - 性能问题: 每次调用
select()都需要将整个fd_set从用户空间拷贝到内核空间,并且内核需要线性扫描所有 fd,当 fd 数量很多时,性能会急剧下降。
- 数量限制:
poll
poll 改进了 select 的数量限制问题,它使用 pollfd 结构数组,理论上可以监视任意数量的 fd。
- 缺点: 仍然存在性能问题,因为内核仍然需要线性扫描所有
pollfd结构。
epoll (Linux 高性能网络的核心)
epoll 是 Linux 特有的,是 select 和 poll 的增强版,是构建高性能网络服务器的首选。
- 原理:
epoll_create(): 在内核中创建一个epoll实例,返回一个 fd。epoll_ctl(): 向epoll实例中添加、修改或删除要监视的 fd。epoll_wait(): 阻塞等待,直到被监视的 fd 中有事件发生(如可读、可写),它只返回发生事件的 fd,无需扫描所有 fd。
- 优点:
- 没有数量限制: 所能监视的 fd 数量只受系统内存限制。
- 效率高:
epoll_wait()只返回就绪的 fd,避免了select和poll的线性扫描。 - 支持边缘触发 (Edge-Triggered, ET): 这是
epoll最强大的特性,LT(水平触发)是默认的,只要 fd 处于就绪状态,每次epoll_wait()都会返回它,而 ET 模式下,只有 fd 从不可变为可(状态发生变化)时,epoll_wait()才会通知一次,这要求我们必须一次性将数据读完,否则可能会错过事件,但能极大提高效率。
多线程/多进程模型
I/O 多路复用解决了“监视”多个连接的问题,但“处理”这些连接仍然需要并发。
- 多进程模型 (fork):
accept()一个连接后,fork()一个子进程来处理该连接。- 优点:编程简单,进程间数据隔离,安全性高。
- 缺点:创建进程的开销大,进程间通信复杂。
- 多线程模型 (pthread):
accept()一个连接后,创建一个新线程来处理该连接。- 优点:创建线程的开销比进程小,共享内存方便。
- 缺点:线程间共享数据需要加锁,编程更复杂,有死锁风险。
- 线程池模型 (I/O 多路复用 + 线程池):
- 这是最常用的高性能模型,主线程负责
epoll_wait(),当有连接就绪时,将任务(fd)放入一个任务队列。 - 一个预先创建好的线程池从队列中取出任务,并进行处理。
- 优点:避免了频繁创建/销毁线程的开销,资源利用率高,是 C10k 问题的标准解决方案。
- 这是最常用的高性能模型,主线程负责
实战项目:简单聊天室
让我们用 epoll + 多线程来实现一个简单的、支持多客户端的聊天室。
需求分析
- 服务器启动,监听指定端口。
- 多个客户端可以连接到服务器。
- 任何一个客户端发送的消息,服务器会广播给所有其他在线客户端。
- 客户端断开连接时,服务器能感知并移除它。
设计思路
- 主线程: 负责
epoll的创建、事件循环 (epoll_wait) 和新连接的接受 (accept),当有新连接时,将其fd添加到epoll实例中,并设置为 ET (Edge-Triggered) 模式。 - 工作线程池: 负责处理已连接的客户端的读写事件,从
epoll_wait返回后,将fd放入一个任务队列。 - 任务队列: 线程安全的队列,用于在主线程和工作线程之间传递
fd。 - 用户列表: 一个全局数据结构(如链表),存储所有在线客户端的
fd和信息,当有客户端发来消息时,遍历此列表进行广播。
代码实现 (简化版)
这是一个非常复杂的工程,这里只展示核心逻辑。
chat_server.c (核心逻辑)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <pthread.h>
#define MAX_EVENTS 1024
#define PORT 8888
#define BUFFER_SIZE 1024
// 设置 fd 为非阻塞
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl F_GETFL");
return -1;
}
if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl F_SETFL O_NONBLOCK");
return -1;
}
return 0;
}
// 全局变量和函数声明 (线程安全、用户列表等)
// ...
int main() {
int listen_fd, conn_fd;
struct epoll_event ev, events[MAX_EVENTS];
int epoll_fd;
// 1. 创建监听套接字
listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0); // 直接创建为非阻塞
if (listen_fd < 0) { /* ... error ... */ }
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
listen(listen_fd, SOMAXCONN);
// 2. 创建 epoll 实例
epoll_fd = epoll_create1(0);
if (epoll_fd < 0) { /* ... error ... */ }
// 3. 添加监听 fd 到 epoll
ev.events = EPOLLIN; // 监听可读事件
ev.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) < 0) { /* ... error ... */ }
printf("Chat server started on port %d\n", PORT);
// 4. 事件循环
while (1) {
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // 阻塞等待
if (nfds < 0) { /* ... error ... */ }
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) {
// 新连接
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
while ((conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len)) > 0) {
printf("New connection from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
set_nonblocking(conn_fd); // 设置新连接为非阻塞
ev.events = EPOLLIN | EPOLLET; // ET 模式
ev.data.fd = conn_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
}
} else if (events[i].events & EPOLLIN) {
// 已连接的 fd 有数据可读 (ET模式)
int client_fd = events[i].data.fd;
char buffer[BUFFER_SIZE];
ssize_t count;
// ET 模式必须循环读取,直到返回 EAGAIN
while ((count = read(client_fd, buffer, BUFFER_SIZE - 1)) > 0) {
buffer[count] = '\0';
printf("Received from client %d: %s\n", client_fd, buffer);
// TODO: 将广播任务放入线程池队列
}
if (count == 0) {
// 客户端关闭连接
printf("Client %d disconnected\n", client_fd);
close(client_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
} else if (count < 0 && errno != EAGAIN) {
// 发生错误
perror("read");
close(client_fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
}
}
// TODO: 处理 EPOLLOUT 事件 (发送数据)
}
}
close(listen_fd);
close(epoll_fd);
return 0;
}
注意: 这是一个简化版本,省略了线程池、任务队列、用户列表、广播逻辑和线程同步(互斥锁)等关键部分,实现一个完整的聊天室是本教程的最佳实践项目。
高级主题与最佳实践
原子操作与锁
在多线程/多进程服务器中,多个线程可能同时访问共享资源(如用户列表、全局计数器),这会导致 竞态条件,必须使用 锁(如 pthread_mutex_t)来保护这些共享资源,确保同一时间只有一个线程能访问。
setsockopt 与 getsockopt
这两个函数用于设置和获取套接字的选项。
- 常用选项:
SO_REUSEADDR: 防止 "Address already in use" 错误,允许快速重启服务器。SO_KEEPALIVE: 启动 TCP 的保活机制,检测死连接。TCP_NODELAY: 禁用 Nagle 算法,减少小数据包的延迟,适合实时性要求高的场景。
优雅关闭
当服务器需要关闭时,不能简单地 exit(),应该:
- 停止接受新连接 (
shutdown(listen_fd, SHUT_RD))。 - 遍历所有已连接的客户端,向它们发送一个 "Server is shutting down" 的消息。
- 等待客户端处理完数据并主动断开连接。
- 超时后,强制关闭所有连接。
- 释放所有资源(内存、套接字等)。
调试技巧
strace: 跟踪程序调用的系统调用和接收到的信号。strace -e trace=network ./your_server
netstat/ss: 查看网络连接状态。ss -tulpn | grep :8080
tcpdump: 抓取和分析网络数据包。tcpdump -i lo -nn port 8080
- GDB: 使用 GDB 的
attach命令可以附加到正在运行的进程进行调试。
推荐资源
书籍
- 《UNIX 网络编程 卷1:套接字联网API (第3版)》 - by W. Richard Stevens, Bill Fenner, Andrew M. Rudoff. (圣经级著作,必读)
- 《Linux多线程服务端编程》 - by 陈硕. (国内经典,讲解 C++ 实现,但设计思想和并发模型对 C 语言同样适用)
- 《TCP/IP详解 卷1:协议》 - by W. Richard Stevens. (深入理解 TCP/IP 协议栈)
在线资源
- Beej's Guide to Network Programming: 一份非常友好、实用的英文网络编程入门指南,有中文翻译版。
- man 手册: Linux 下最权威的文档,使用
man 7 socket,man 2 accept,man 2 epoll等命令查阅系统调用和库函数的详细说明。 - GitHub: 搜索一些简单的网络库或项目,如
libevent,muduo(陈硕的 C++ 网络库),阅读其源码是提升水平的绝佳方式。
Linux 网络编程是一个广阔而深入的领域,本教程为你铺平了从基础到进阶的道路,请务必:
- 动手实践: 把每个例子都敲一遍,修改它,破坏它,理解它。
- 深入阅读: 阅读《UNIX 网络编程》,理解其背后的设计哲学。
- 挑战项目: 尝试实现一个简单的 HTTP 服务器、一个 RPC 框架,或者一个聊天室。
祝你学习愉快!
