引言
摘要:本文是一份面向已掌握 C 语言基础、希望入门 Linux 系统编程的开发者的完整学习笔记,覆盖四大核心主题:文件 I/O(open/read/write/close 等系统调用与「一切皆文件」哲学)、进程与线程(fork/exec/wait 创建回收进程,pthread 创建线程,并用互斥锁、条件变量解决并发同步)、Socket 网络编程(TCP 服务器与客户端的完整编程流程,以及用 fork/pthread 实现多客户端并发处理)、Makefile 编写(变量、模式规则、自动依赖生成与伪目标 clean)。全文配有大量可直接编译运行的 C 示例代码、TCP 编程流程 ASCII 图、线程池实现与并发服务器对比,并给出学习路径、调试工具(man/strace/gdb)与经典参考书籍,帮助读者按「文件 I/O → 进程 → 线程 → Socket → Makefile」的顺序循序渐进地跑通每个示例。
本文面向已经掌握 C 语言基础、希望入门 Linux 系统编程的开发者。如果你熟悉指针、结构体、函数指针等 C 语言核心概念,但对「程序如何与操作系统打交道」还比较陌生,那么这份笔记正是为你准备的。
通过本文,你将掌握 Linux 系统编程的四大核心技能:
- 文件 I/O:理解「一切皆文件」的设计哲学,熟练使用 open/read/write/close 等系统调用读写文件。
- 进程与线程:掌握 fork/exec/wait 创建和回收进程,用 pthread 创建线程,并用互斥锁、条件变量解决并发同步问题。
- Socket 网络编程:掌握 TCP 服务器与客户端的完整编程流程,理解 socket -> bind -> listen -> accept -> recv/send -> close 与 socket -> connect -> send/recv -> close 的每一步,并能用 fork 或 pthread 实现多客户端并发处理。
- Makefile 编写:学会用 Makefile 描述编译规则,避免重复输入 gcc 命令,掌握变量、模式规则、自动依赖生成等进阶用法。
建议的学习路径:按章节顺序依次阅读——先打好文件 I/O 的基础,再理解进程与线程的并发模型,接着进入 Socket 网络编程,最后用 Makefile 把前面所有示例统一管理起来。每读完一个章节,请务必动手编译并运行其中的每个示例,在终端里亲眼看到输出结果,再进入下一节。编程是实践的艺术,只有亲手跑通代码,才能真正内化这些系统调用的用法与边界条件。
下面是一份 Linux 系统编程入门笔记,覆盖四个核心主题:文件 I/O、进程/线程创建、Socket 网络编程、Makefile 编写。默认环境为 Linux + gcc + make。
目录
- 一、Linux 文件 I/O
- 二、进程与线程创建
- 三、网络 Socket 编程
- 四、Makefile 编写
一、Linux 文件 IO
Linux 中“一切皆文件”,文件 I/O 主要使用系统调用::
函数作用
open打开/创建文件
read读文件
write写文件
close关闭文件
lseek移动文件偏移
stat/fstat获取文件信息
文件描述符:0 标准输入,1 标准输出,2 标准错误。
示例:复制文件 copy.c
c
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, “Usage: %s \\n”, argv[0]);
return 1;
}
int in = open(argv[1], O_RDONLY);
if (in < 0) {
perror("open src");
return 1;
}
int out = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (out < 0) {
perror("open dst");
close(in);
return 1;
}
char buf[4096];
ssize_t n;
while ((n = read(in, buf, sizeof(buf))) > 0) {
ssize_t off = 0;
while (off < n) {
ssize_t w = write(out, buf + off, n – off);
if (w < 0) {
perror("write");
close(in);
close(out);
return 1;
}
off += w;
}
}
if (n < 0) {
perror("read");
}
close(in);
close(out);
return 0;
}
编译运行:
bash
gcc -Wall -g -o copy copy.c
./copy a.txt b.txt
注意:read/write 可能返回少于请求的字节数,所以写操作通常要循环写。
二、进程与线程创建
•fork():创建子进程,父进程返回子进程 PID,子进程返回 0。
•exec*():用新程序替换当前进程映像。
•wait/waitpid:回收子进程,避免僵尸进程。
示例:fork_demo.c
c
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
// 子进程
printf("child pid=%d, parent=%d\\n", getpid(), getppid());
execlp("ls", "ls", "-l", NULL);
perror("execlp");
_exit(127);
}
// 父进程
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("child exited with %d\\n", WEXITSTATUS(status));
}
return 0;
}
编译:
bash
gcc -Wall -g -o fork_demo fork_demo.c
2. 线程:pthread
线程共享同一进程地址空间,创建开销小于进程。
函数作用
pthread_create创建线程
pthread_join等待线程结束
pthread_mutex_lock/unlock互斥锁
pthread_exit退出线程
示例:thread_demo.c
c
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
void *worker(void *arg) {
int id = *(int *)arg;
printf(“thread %d, tid=%lu\\n”, id, (unsigned long)pthread_self());
return NULL;
}
int main() {
pthread_t t[3];
int ids[3];
for (int i = 0; i < 3; i++) {
ids[i] = i;
if (pthread_create(&t[i], NULL, worker, &ids[i]) != 0) {
perror("pthread_create");
return 1;
}
}
for (int i = 0; i < 3; i++) {
pthread_join(t[i], NULL);
}
return 0;
}
编译时必须加 -pthread:
bash
示例:使用互斥锁保护共享计数器 mutex_demo.c
c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define THREAD_NUM 5
#define LOOP_NUM 100000
static int counter = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *worker(void *arg) {
(void)arg;
for (int i = 0; i < LOOP_NUM; i++) {
pthread_mutex_lock(&lock); // 加锁,进入临界区
counter++; // 共享变量,必须互斥访问
pthread_mutex_unlock(&lock); // 解锁,离开临界区
}
return NULL;
}
int main() {
pthread_t t[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
if (pthread_create(&t[i], NULL, worker, NULL) != 0) {
perror("pthread_create");
return 1;
}
}
for (int i = 0; i < THREAD_NUM; i++) {
pthread_join(t[i], NULL);
}
printf("counter = %d (expected %d)\\n", counter, THREAD_NUM * LOOP_NUM);
return 0;
}
编译运行:
bash
gcc -Wall -g -pthread -o mutex_demo mutex_demo.c
./mutex_demo
输出:
text
counter = 500000 (expected 500000)
说明:若去掉加锁/解锁,counter 最终结果往往小于 500000,因为多个线程同时执行 counter++ 会产生竞态条件(read-modify-write 不是原子操作)。
互斥锁使用场景:
•多个线程同时读写同一个共享变量(如计数器、缓存、队列)。
•多个线程同时操作同一个文件描述符或全局数据结构。
•需要保证“检查-修改-写入”这类复合操作不被并发打断。
注意事项:
•加锁后必须解锁:每个 pthread_mutex_lock 都要有对应的 pthread_mutex_unlock,否则其他线程会永久阻塞。
•避免死锁:不要在一个线程里同时持有多个锁时以相反顺序加锁;可用 pthread_mutex_trylock 或统一加锁顺序来规避。
•锁粒度控制:临界区尽量短,只保护真正需要互斥的代码,不要在锁内做耗时的 IO 或 sleep,否则会降低并发度。
•初始化与销毁:可用 PTHREAD_MUTEX_INITIALIZER 静态初始化,或用 pthread_mutex_init 动态初始化;不再使用时调用 pthread_mutex_destroy 释放资源。
示例:基于 pthread 的线程池 thread_pool.c
线程池预先创建一组工作线程,通过任务队列接收任务,配合条件变量实现「有任务就唤醒、无任务就休眠」,避免频繁创建/销毁线程的开销。
c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define THREAD_NUM 4
#define TASK_NUM 10
// 任务节点
typedef struct Task {
void (*func)(void *arg);
void *arg;
struct Task *next;
} Task;
// 线程池
typedef struct {
Task *head; // 任务队列头
Task *tail; // 任务队列尾
int shutdown; // 是否关闭线程池
pthread_mutex_t lock; // 保护任务队列的互斥锁
pthread_cond_t cond; // 条件变量:队列非空时唤醒工作线程
pthread_t threads[THREAD_NUM];
} ThreadPool;
// 简单任务:打印线程 id 和任务编号
void do_work(void *arg) {
int id = *(int *)arg;
printf(“task %d handled by thread %lu\\n”, id, (unsigned long)pthread_self());
usleep(100000); // 模拟耗时操作
}
// 工作线程主函数
void *worker(void *arg) {
ThreadPool *pool = (ThreadPool *)arg;
while (1) {
pthread_mutex_lock(&pool->lock);
// 队列为空且未关闭时,等待条件变量
while (pool->head == NULL && !pool->shutdown) {
pthread_cond_wait(&pool->cond, &pool->lock);
}
// 关闭且队列为空,退出线程
if (pool->shutdown && pool->head == NULL) {
pthread_mutex_unlock(&pool->lock);
break;
}
// 取出队首任务
Task *task = pool->head;
pool->head = task->next;
if (pool->head == NULL) {
pool->tail = NULL;
}
pthread_mutex_unlock(&pool->lock);
// 执行任务(在锁外执行,避免长时间持锁)
task->func(task->arg);
free(task);
}
return NULL;
}
// 初始化线程池:创建 THREAD_NUM 个工作线程
void pool_init(ThreadPool *pool) {
pool->head = NULL;
pool->tail = NULL;
pool->shutdown = 0;
pthread_mutex_init(&pool->lock, NULL);
pthread_cond_init(&pool->cond, NULL);
for (int i = 0; i < THREAD_NUM; i++) {
pthread_create(&pool->threads[i], NULL, worker, pool);
}
}
// 提交任务:追加到队列尾部并唤醒一个工作线程
void pool_submit(ThreadPool *pool, void (*func)(void *), void *arg) {
Task *task = (Task *)malloc(sizeof(Task));
task->func = func;
task->arg = arg;
task->next = NULL;
pthread_mutex_lock(&pool->lock);
if (pool->tail == NULL) {
pool->head = task;
pool->tail = task;
} else {
pool->tail->next = task;
pool->tail = task;
}
pthread_cond_signal(&pool->cond); // 唤醒一个等待中的工作线程
pthread_mutex_unlock(&pool->lock);
}
// 销毁线程池:置 shutdown 标志,广播唤醒所有线程并回收
void pool_destroy(ThreadPool *pool) {
pthread_mutex_lock(&pool->lock);
pool->shutdown = 1;
pthread_cond_broadcast(&pool->cond); // 唤醒所有等待线程
pthread_mutex_unlock(&pool->lock);
for (int i = 0; i < THREAD_NUM; i++) {
pthread_join(pool->threads[i], NULL);
}
pthread_mutex_destroy(&pool->lock);
pthread_cond_destroy(&pool->cond);
}
int main() {
ThreadPool pool;
pool_init(&pool);
int ids[TASK_NUM];
for (int i = 0; i < TASK_NUM; i++) {
ids[i] = i;
pool_submit(&pool, do_work, &ids[i]);
}
pool_destroy(&pool);
printf("all tasks done\\n");
return 0;
}
编译运行:
bash
gcc -Wall -g -pthread -o thread_pool thread_pool.c
./thread_pool
输出示例:
text
task 0 handled by thread 139912345678912
task 1 handled by thread 139912345678913
task 2 handled by thread 139912345678914
task 3 handled by thread 139912345678915
task 4 handled by thread 139912345678912
task 5 handled by thread 139912345678913
…
all tasks done
说明:tid 具体数值因系统而异,关键是 4 个工作线程并发消费 10 个任务,每个任务只被处理一次。
线程池要点:
•任务队列 + 互斥锁:所有对队列的读写(提交、取出)都要在 lock 保护下进行,避免竞态条件。
•条件变量:队列为空时工作线程 pthread_cond_wait 休眠并释放锁;提交任务后 pthread_cond_signal 唤醒一个线程。用 while 而非 if 检查条件,防止「虚假唤醒」。
•任务在锁外执行:取出任务后先解锁再执行,避免长时间持锁阻塞其他线程提交任务。
•关闭流程:置 shutdown 标志后用 pthread_cond_broadcast 唤醒所有线程,线程发现队列为空且已关闭即退出,最后 join 回收。
示例:使用条件变量实现线程池 thread_pool_cond.c
下面是一个更完整的线程池实现,重点展示条件变量在「任务队列为空时让工作线程休眠、有新任务时唤醒」这一协作机制中的核心作用。代码包含任务队列、互斥锁、条件变量、线程创建与销毁,并对关键步骤加了注释。
c
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define THREAD_NUM 4
#define TASK_NUM 10
// 任务节点:保存要执行的函数及其参数
typedef struct Task {
void (*func)(void *arg);
void *arg;
struct Task *next;
} Task;
// 线程池结构
typedef struct {
Task *head; // 任务队列头指针
Task *tail; // 任务队列尾指针
int shutdown; // 关闭标志:1 表示线程池正在关闭
pthread_mutex_t lock; // 互斥锁:保护任务队列的并发访问
pthread_cond_t cond; // 条件变量:队列非空时唤醒工作线程
pthread_t threads[THREAD_NUM]; // 工作线程数组
} ThreadPool;
// 模拟任务:打印线程 id 和任务编号
void do_work(void *arg) {
int id = *(int *)arg;
printf(“task %d handled by thread %lu\\n”, id, (unsigned long)pthread_self());
usleep(100000); // 模拟耗时操作
}
// 工作线程主函数:循环从队列取任务执行
void *worker(void *arg) {
ThreadPool *pool = (ThreadPool *)arg;
while (1) {
// 1. 加锁:访问任务队列前必须先获取互斥锁
pthread_mutex_lock(&pool->lock);
// 2. 用 while 而非 if 检查条件,防止「虚假唤醒」:
// 队列为空且未关闭时,释放锁并休眠,等待条件变量被 signal/broadcast
while (pool->head == NULL && !pool->shutdown) {
pthread_cond_wait(&pool->cond, &pool->lock);
}
// 3. 线程池已关闭且队列为空,退出线程
if (pool->shutdown && pool->head == NULL) {
pthread_mutex_unlock(&pool->lock);
break;
}
// 4. 取出队首任务
Task *task = pool->head;
pool->head = task->next;
if (pool->head == NULL) {
pool->tail = NULL;
}
// 5. 解锁:取出任务后立即释放锁,避免长时间持锁阻塞其他线程
pthread_mutex_unlock(&pool->lock);
// 6. 在锁外执行任务,提高并发度
task->func(task->arg);
free(task); // 任务执行完毕,释放内存
}
return NULL;
}
// 初始化线程池:初始化锁和条件变量,创建 THREAD_NUM 个工作线程
void pool_init(ThreadPool *pool) {
pool->head = NULL;
pool->tail = NULL;
pool->shutdown = 0;
// 初始化互斥锁和条件变量
pthread_mutex_init(&pool->lock, NULL);
pthread_cond_init(&pool->cond, NULL);
// 创建固定数量的工作线程
for (int i = 0; i < THREAD_NUM; i++) {
pthread_create(&pool->threads[i], NULL, worker, pool);
}
}
// 提交任务:追加到队列尾部,并唤醒一个等待中的工作线程
void pool_submit(ThreadPool *pool, void (*func)(void *), void *arg) {
// 创建任务节点
Task *task = (Task *)malloc(sizeof(Task));
task->func = func;
task->arg = arg;
task->next = NULL;
// 加锁后修改队列
pthread_mutex_lock(&pool->lock);
if (pool->tail == NULL) {
pool->head = task;
pool->tail = task;
} else {
pool->tail->next = task;
pool->tail = task;
}
// 唤醒一个正在 pthread_cond_wait 休眠的工作线程
pthread_cond_signal(&pool->cond);
pthread_mutex_unlock(&pool->lock);
}
// 销毁线程池:置关闭标志,广播唤醒所有线程,回收资源
void pool_destroy(ThreadPool *pool) {
// 加锁并置 shutdown 标志
pthread_mutex_lock(&pool->lock);
pool->shutdown = 1;
// 广播唤醒所有等待中的工作线程,让它们检查 shutdown 后退出
pthread_cond_broadcast(&pool->cond);
pthread_mutex_unlock(&pool->lock);
// 等待所有工作线程退出并回收
for (int i = 0; i < THREAD_NUM; i++) {
pthread_join(pool->threads[i], NULL);
}
// 销毁互斥锁和条件变量
pthread_mutex_destroy(&pool->lock);
pthread_cond_destroy(&pool->cond);
}
int main() {
ThreadPool pool;
pool_init(&pool);
int ids[TASK_NUM];
for (int i = 0; i < TASK_NUM; i++) {
ids[i] = i;
pool_submit(&pool, do_work, &ids[i]);
}
pool_destroy(&pool);
printf("all tasks done\\n");
return 0;
}
编译运行:
bash
gcc -Wall -g -pthread -o thread_pool_cond thread_pool_cond.c
./thread_pool_cond
输出示例:
text
task 0 handled by thread 139912345678912
task 1 handled by thread 139912345678913
task 2 handled by thread 139912345678914
task 3 handled by thread 139912345678915
task 4 handled by thread 139912345678912
task 5 handled by thread 139912345678913
…
all tasks done
说明:tid 具体数值因系统而异,关键是 4 个工作线程并发消费 10 个任务,每个任务只被处理一次。
条件变量协作机制说明:
•pthread_cond_wait 会原子地释放锁并休眠,被唤醒后重新获取锁,因此必须配合互斥锁使用。
•用 while 循环检查条件而非 if,是为了应对「虚假唤醒」:即使没有 signal,线程也可能被唤醒,需要重新检查条件。
•pthread_cond_signal 唤醒一个线程,适合「来一个任务唤醒一个线程」;pthread_cond_broadcast 唤醒所有线程,适合关闭线程池时让所有线程退出。
•条件变量必须在持有锁的情况下调用 signal/broadcast,否则可能丢失唤醒信号。
•适用场景:任务数量多、单个任务执行时间短、需要限制并发数的场景(如网络请求处理、日志写入);相比每任务一线程,线程池复用线程,减少创建/销毁开销。
•锁不是万能的:锁能保证正确性,但过度加锁会拖慢性能;读多写少场景可考虑读写锁 pthread_rwlock_t。
gcc -Wall -g -pthread -o thread_demo thread_demo.c
三、网络 Socket 编程
TCP 编程流程:
服务器:
socket -> bind -> listen -> accept -> recv/send -> close
服务器端流程(含错误处理):
┌─────────────────────────────────────────────────────────────────────┐
│ TCP 服务器端编程流程 │
└─────────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ socket() │
│ 创建套接字 │
└──────┬───────┘
│
▼
┌──────────────┐ 失败(如协议不支持、内存不足)
│ 返回值 < 0 │──────────────────────────────► perror("socket"); return 1;
└──────┬───────┘
│ 成功
▼
┌──────────────┐
│ bind() │
│ 绑定地址端口 │
└──────┬───────┘
│
▼
┌──────────────┐ 失败(如端口被占用、无权限绑定<1024)
│ 返回值 < 0 │──────────────────────────────► perror("bind"); return 1;
└──────┬───────┘
│ 成功
▼
┌──────────────┐
│ listen() │
│ 进入监听状态 │
└──────┬───────┘
│
▼
┌──────────────┐ 失败(如套接字未绑定、fd 非法)
│ 返回值 < 0 │──────────────────────────────► perror("listen"); return 1;
└──────┬───────┘
│ 成功
▼
┌──────────────┐
│ accept() │
│ 接受客户端连接│
└──────┬───────┘
│
▼
┌──────────────┐ 失败(如连接被中断、fd 非法)
│ 返回值 < 0 │──────────────────────────────► perror("accept"); continue;
└──────┬───────┘
│ 成功(获得新连接 fd)
▼
┌──────────────────────────────────────────────┐
│ recv() / send() │
│ 循环收发数据 │
│ recv 返回 0 → 客户端关闭连接,退出循环 │
│ recv 返回 <0 → 出错,perror("recv") 后退出 │
│ send 返回 <0 → 出错,perror("send") 后退出 │
└──────────────────────┬───────────────────────┘
│ 数据收发完毕
▼
┌──────────────┐
│ close(fd) │
│ 关闭连接套接字│
└──────────────┘
客户端流程(含错误处理):
┌─────────────────────────────────────────────────────────────────────┐
│ TCP 客户端编程流程 │
└─────────────────────────────────────────────────────────────────────┘
┌──────────────┐
│ socket() │
│ 创建套接字 │
└──────┬───────┘
│
▼
┌──────────────┐ 失败(如协议不支持、内存不足)
│ 返回值 < 0 │──────────────────────────────► perror("socket"); return 1;
└──────┬───────┘
│ 成功
▼
┌──────────────┐
│ connect() │
│ 连接服务器 │
└──────┬───────┘
│
▼
┌──────────────┐ 失败(如服务器未启动、端口错误、网络不通)
│ 返回值 < 0 │──────────────────────────────► perror("connect"); return 1;
└──────┬───────┘
│ 成功
▼
┌──────────────────────────────────────────────┐
│ send() / recv() │
│ 循环收发数据 │
│ send 返回 <0 → 出错,perror("send") 后退出 │
│ recv 返回 0 → 服务器关闭连接,退出循环 │
│ recv 返回 <0 → 出错,perror("recv") 后退出 │
└──────────────────────┬───────────────────────┘
│ 数据收发完毕
▼
┌──────────────┐
│ close(fd) │
│ 关闭套接字 │
└──────────────┘
客户端:
socket -> connect -> send/recv -> close
客户端流程相对简单:socket 创建套接字 → connect 连接服务器 → send/recv 循环收发数据 → close 关闭连接。下面是与 tcp_server.c 配套的客户端示例。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define PORT 8080
#define BUF_SIZE 1024
int main(int argc, char *argv[]) {
const char *host = argc > 1 ? argv[1] : "127.0.0.1";
// 1. socket:创建 TCP 套接字
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
// 2. 构造服务器地址结构
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
// 把点分十进制 IP 转换为网络字节序
if (inet_pton(AF_INET, host, &addr.sin_addr) <= 0) {
perror("inet_pton");
close(fd);
return 1;
}
// 3. connect:连接服务器
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
close(fd);
return 1;
}
printf("connected to %s:%d, type 'quit' to exit\\n", host, PORT);
// 4. send/recv:循环收发数据
char buf[BUF_SIZE];
while (fgets(buf, sizeof(buf), stdin)) {
// 去掉末尾换行,便于判断 quit
buf[strcspn(buf, "\\n")] = '\\0';
if (strcmp(buf, "quit") == 0) {
break;
}
// 发送数据(简化版,生产代码需循环发送)
if (send(fd, buf, strlen(buf), 0) < 0) {
perror("send");
break;
}
// 接收服务器回显
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) {
perror("recv");
break;
}
buf[n] = '\\0';
printf("echo: %s\\n", buf);
}
// 5. close:关闭连接
close(fd);
return 0;
}
编译运行:
gcc -Wall -g -o tcp_client tcp_client.c
./tcp_client 127.0.0.1
输出示例:
connected to 127.0.0.1:8080, type 'quit' to exit
hello
echo: hello
world
echo: world
quit
流程说明:
- socket:创建 TCP 套接字,返回文件描述符 fd。
- connect:向服务器发起连接,失败时返回 -1(如服务器未启动、端口错误、网络不通)。
- send/recv:send 发送用户输入,recv 接收服务器回显;recv 返回 0 表示服务器关闭连接,返回 -1 表示出错。
- close:关闭套接字,释放文件描述符。
注意事项:
- 字节序:htons 把端口号转换为网络字节序,inet_pton 把 IP 字符串转换为二进制地址。
- 循环收发:send/recv 可能只发送/接收部分数据,生产代码需要循环处理,这里为演示做了简化。
- 与服务器联调:先启动 ./tcp_server,再运行 ./tcp_client 127.0.0.1,输入任意文本即可看到服务器回显。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror(“socket”);
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
// 回显循环:recv 收到数据后原样 send 回去
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// send 可能只发送部分数据,需循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
// 客户端关闭连接或出错,关闭连接套接字
close(fd);
printf("client %s:%d disconnected\\n", ip, ntohs(cli.sin_port));
}
close(server_fd);
return 0;
}
**编译运行:**
```bash
gcc -Wall -g -o tcp_server tcp_server.c
./tcp_server
输出示例:
listening on port 8080…
client 127.0.0.1:52341
client 127.0.0.1:52342
client 127.0.0.1:52341 disconnected
说明:
- accept 循环:服务器在 while (1) 中反复调用 accept,每接受一个客户端连接就进入回显循环,处理完该连接后关闭 fd,再回到 accept 等待下一个客户端。
- recv/send 回显:recv 返回 0 表示客户端正常关闭连接,返回 -1 表示出错;收到数据后 send 原样返回,且 send 也可能只发送部分数据,因此用内层循环确保全部发完。
- close 关闭连接:回显循环结束后调用 close(fd) 关闭连接套接字,释放该连接占用的资源;监听套接字 server_fd 在整个程序生命周期内保持打开。
- 单线程模型:这个版本同一时刻只能服务一个客户端,处理完当前连接才会 accept 下一个;要支持多客户端并发,请参考后面的 tcp_server_fork.c 和 tcp_server_thread.c。
cp_server tcp_server.c
./tcp_server
**输出示例:**
```text
listening on port 8080…
client 127.0.0.1:52341
流程说明:
- accept:接受客户端连接,返回新的连接套接字 fd;失败时 perror("accept") 后 continue 继续等待下一个连接。
- recv/send 回显:recv 循环读取客户端数据,send 原样回显;recv 返回 0 表示客户端正常关闭连接,返回 -1 表示出错(如对端异常断开收到 RST)。
- close:数据收发完毕后 close(fd) 关闭连接套接字,释放文件描述符;主循环继续 accept 下一个客户端。
注意事项:
-
send 也要循环:send 和 recv 一样可能只发送部分数据,生产代码应循环发送直到全部发完,或处理 EINTR 中断错误。
-
单线程模型:该版本 accept 后立即处理当前连接的收发,处理完才 accept 下一个,同一时刻只能服务一个客户端;若某个客户端长时间不发送数据,后续客户端会被阻塞在 accept 队列里。要支持多客户端并发,可改用 fork 或 pthread 版本(见后文)。
close(server_fd);
return 0;
}
多线程并发回显服务器 tcp_server_thread.c
上面的 tcp_server.c 是单线程模型:accept 之后在同一个循环里处理当前连接的收发,处理完一个客户端才会 accept 下一个,因此同一时刻只能服务一个客户端。下面使用 pthread 为每个客户端连接创建一个独立线程处理 recv/send,主线程继续 accept 新连接,从而支持多客户端并发。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 线程函数:处理单个客户端的收发(回显)
void *handle_client(void *arg) {
int fd = *(int *)arg;
free(arg); // 释放传入的 fd 副本
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
send(fd, buf, n, 0); // 回显
}
close(fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
// 为每个连接创建一个独立线程处理收发
pthread_t tid;
int *pfd = (int *)malloc(sizeof(int));
*pfd = fd;
if (pthread_create(&tid, NULL, handle_client, pfd) != 0) {
perror("pthread_create");
close(fd);
free(pfd);
continue;
}
// 分离线程:线程结束后自动回收资源,无需 join
pthread_detach(tid);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -pthread -o tcp_server_thread tcp_server_thread.c
./tcp_server_thread
输出示例:
listening on port 8080…
client 127.0.0.1:52341
client 127.0.0.1:52342
与单线程版本的对比:
- 并发能力提升:单线程版本 accept 后立即处理该连接的收发,处理完才 accept 下一个。若某个客户端长时间不发送数据,后续客户端会被阻塞在 accept 队列里,无法及时服务。多线程版本主线程只负责 accept,每来一个连接就创建一个线程去处理,主线程立刻回到 accept,因此多个客户端可以同时被服务,互不阻塞。
- 需注意线程安全:线程共享同一进程的地址空间,若多个线程同时读写同一全局变量(如计数器、日志缓冲区)必须加锁保护,否则会产生竞态条件。
- 需注意资源限制:每来一个连接就创建一个线程,线程数会随并发量增长。线程创建虽比 fork 开销小,但仍有栈空间(默认约 8MB 虚拟内存)等资源消耗,连接数极大时可能耗尽系统资源,高并发场景建议改用线程池或 I/O 多路复用(select/poll/epoll)。
关键点说明:
- 线程参数传递:这里把 fd 拷贝到堆上再传给线程,避免多个线程共享同一个栈变量导致数据竞争;线程函数内用完即 free。
- 线程分离:用 pthread_detach 让线程结束后自动回收资源,避免线程资源泄漏;若用 pthread_join 则需保存每个线程 id 并逐一回收。
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define PORT 8080
#define BUF_SIZE 1024
int main(int argc, char *argv[]) {
const char *host = argc > 1 ? argv[1] : “127.0.0.1”;
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd < 0) {
perror("socket");
return 1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
if (inet_pton(AF_INET, host, &addr.sin_addr) <= 0) {
perror("inet_pton");
return 1;
}
if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
return 1;
}
char buf[BUF_SIZE];
while (fgets(buf, sizeof(buf), stdin)) {
send(fd, buf, strlen(buf), 0);
ssize_t n = recv(fd, buf, sizeof(buf), 0);
if (n <= 0) break;
fwrite(buf, 1, n, stdout);
}
close(fd);
return 0;
}
编译:
bash
gcc -Wall -g -o tcp_server tcp_server.c
gcc -Wall -g -o tcp_client tcp_client.c
运行:
bash
Socket 编程常见错误与排查
网络编程中,很多问题并非代码逻辑错误,而是环境、端口、连接状态等外部因素导致。下面总结最常见的几类错误及其排查方法。
bind 失败
bind 返回 -1 时,常见原因有两个:
- 端口被占用:另一个进程已经绑定了同一个端口。此时 perror("bind") 会输出 Address already in use。可用 netstat -tlnp 查看端口占用情况,找到占用进程后 kill 掉,或换一个端口。
- 无权限绑定 1024 以下端口:Linux 规定只有 root 用户才能绑定 1~1023 的端口。若用普通用户绑定 80 端口,会报 Permission denied。解决方法是改用 1024 以上的端口(如 8080),或用 sudo 运行。
排查命令:
netstat -tlnp | grep 8080 # 查看 8080 端口是否被占用
lsof -i :8080 # 查看占用 8080 端口的进程
connect 失败
connect 返回 -1 时,常见原因:
- 服务器未启动:客户端连接时服务器进程还没运行,会报 Connection refused。先确认服务器已启动并处于监听状态。
- 端口错误:客户端连接的端口与服务器绑定的端口不一致,同样会 Connection refused。
- 网络不通:跨主机连接时,IP 地址错误、防火墙拦截、路由不通都会导致 connect 超时或失败。先 ping 目标主机确认网络连通性。
排查命令:
ping 192.168.1.100 # 确认网络连通
telnet 192.168.1.100 8080 # 测试端口是否可连接
netstat -tlnp | grep 8080 # 确认服务器在监听
recv/send 返回 0 或 -1 的含义
- recv 返回 0:表示对端已正常关闭连接(发送了 FIN)。此时应退出收发循环并 close(fd),不要再继续 recv。
- recv 返回 -1:表示出错。常见原因是对端异常断开(收到 RST),或 fd 已关闭。此时应 perror("recv") 查看具体错误,并关闭 fd。
- send 返回 -1:表示发送失败,常见原因是连接已断开(对端关闭或网络异常)。同样用 perror("send") 定位。
- send 返回小于请求长度:TCP 是字节流协议,send 可能只发送部分数据,必须循环发送直到全部发完,否则数据会丢失。
使用 strace 和 netstat 定位问题
strace 可以跟踪程序的所有系统调用,直接看到 socket、bind、connect、recv、send 的返回值,是定位网络问题最有力的工具:
strace -f -e trace=network ./tcp_server
strace -f -e trace=network ./tcp_client 127.0.0.1
输出会显示每一步系统调用的参数和返回值,例如:
socket(AF_INET, SOCK_STREAM, 0) = 3
bind(3, {sa_family=AF_INET, sin_port=htons(8080), sin_addr=inet_addr("0.0.0.0")}, 16) = 0
listen(3, 10) = 0
accept(3, …) = 4
recvfrom(4, "hello", 1024, 0, …) = 5
如果某一步返回 -1,后面会跟着 errno 和错误描述,能精确定位失败原因。
netstat 用于查看端口监听和连接状态:
netstat -tlnp # 查看所有监听端口及对应进程
netstat -tnp # 查看所有 TCP 连接状态
常见的连接状态:
- LISTEN:服务器正在监听端口,等待连接。
- ESTABLISHED:连接已建立,正在通信。
- TIME_WAIT:连接已关闭,但端口仍被占用一段时间(约 2 分钟),此时若立即重启服务器可能导致 bind 失败,可用 SO_REUSEADDR 解决。
进阶示例:支持多客户端并发处理的 TCP 回显服务器
上面的 tcp_server.c 是单线程模型:accept 之后在同一个循环里处理当前连接的收发,处理完一个客户端才会 accept 下一个,因此同一时刻只能服务一个客户端。要支持多客户端并发,常用两种方式:fork 出子进程,或为每个连接创建 pthread 线程。下面分别给出两种实现,并对比其优缺点。
方式一:使用 fork 实现并发服务器 tcp_server_fork.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 子进程处理函数:循环收发数据(回显)
void handle_client(int fd) {
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// 注意:send 也可能只发送部分数据,生产代码应循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
close(fd);
_exit(0); // 子进程处理完毕,直接退出,不执行父进程的清理代码
}
int main() {
// 忽略 SIGCHLD,让子进程退出后由内核自动回收,避免僵尸进程
signal(SIGCHLD, SIG_IGN);
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(fd);
continue;
}
if (pid == 0) {
// 子进程:处理该连接,处理完退出
close(server_fd); // 子进程不再需要监听套接字
handle_client(fd);
}
// 父进程:关闭已交给子进程的连接 fd,继续 accept 下一个客户端
close(fd);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -o tcp_server_fork tcp_server_fork.c
./tcp_server_fork
方式二:使用 pthread 实现并发服务器 tcp_server_thread.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 线程函数:处理单个客户端的收发(回显)
void *handle_client(void *arg) {
int fd = *(int *)arg;
free(arg); // 释放传入的 fd 副本
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// 注意:send 也可能只发送部分数据,生产代码应循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
close(fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
// 为每个连接创建一个独立线程处理收发
pthread_t tid;
int *pfd = (int *)malloc(sizeof(int));
*pfd = fd;
if (pthread_create(&tid, NULL, handle_client, pfd) != 0) {
perror("pthread_create");
close(fd);
free(pfd);
continue;
}
// 分离线程:线程结束后自动回收资源,无需 join
pthread_detach(tid);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -pthread -o tcp_server_thread tcp_server_thread.c
./tcp_server_thread
两种方式的优缺点对比
| 地址空间 | 子进程拥有独立的地址空间,数据隔离性好,互不干扰 | 线程共享同一进程地址空间,全局变量、堆内存、文件描述符表共享 |
| 创建开销 | fork 需要复制整个进程地址空间(写时复制),开销较大 | pthread_create 只创建线程控制块并分配栈,开销远小于 fork |
| 数据共享 | 子进程间共享数据需要 mmap、管道、共享内存等 IPC 机制,较繁琐 | 天然共享全局变量和堆内存,通信方便,但需加锁保护避免竞态 |
| 健壮性 | 某个子进程崩溃(如段错误)不影响其他子进程和父进程 | 某个线程崩溃可能导致整个进程崩溃,影响所有连接 |
| 资源回收 | 需 wait/waitpid 回收,或 signal(SIGCHLD, SIG_IGN) 让内核自动回收 | 用 pthread_detach 分离线程,结束后自动回收;或 pthread_join 手动回收 |
| 适用场景 | 连接数不多、需要强隔离、或子进程需要独立环境的场景 | 连接数较多、需要频繁创建/销毁、共享数据较多的场景 |
关键注意事项:
- 子进程要关闭 server_fd:fork 后子进程只处理自己的连接 fd,若不关闭监听套接字,会导致父进程无法正常关闭端口。
- 父进程要关闭 fd:父进程把连接 fd 交给子进程/线程后应立即 close(fd),否则 fd 引用计数不为 0,连接不会真正关闭。
- 线程参数要小心:把 fd 拷贝到堆上再传给线程,避免多个线程共享同一个栈变量导致数据竞争;线程函数内用完即 free。
- 高并发场景:每连接一进程/一线程的开销会随并发量线性增长,连接数很大时建议改用线程池或 I/O 多路复用(select/poll/epoll)。
实战示例:支持多客户端并发处理的 TCP 回显服务器
上面的 tcp_server.c 是单线程模型:accept 之后在同一个循环里处理当前连接的收发,处理完一个客户端才会 accept 下一个,因此同一时刻只能服务一个客户端。要支持多客户端并发,常用两种方式:fork 出子进程,或为每个连接创建 pthread 线程。
并发模型的选择依据
- fork 模型:每个客户端连接由独立的子进程处理。子进程拥有独立的地址空间,天然隔离,互不干扰;但 fork 需要复制整个进程地址空间,创建开销较大,且进程间共享数据需要额外的 IPC 机制(如管道、共享内存)。
- pthread 模型:每个客户端连接由独立的线程处理。线程共享同一进程的地址空间,创建开销远小于 fork,适合连接数较多的场景;但线程间共享全局变量,需要加锁保护,否则会产生竞态条件。
选择建议:连接数不多、追求简单隔离时选 fork;连接数较多、追求性能时选 pthread。高并发场景建议进一步使用线程池或 I/O 多路复用(select/poll/epoll)。
示例一:使用 fork 实现并发服务器 tcp_server_fork.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 子进程处理函数:对单个客户端连接进行回显
void handle_client(int fd) {
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
send(fd, buf, n, 0); // 回显
}
close(fd);
_exit(0); // 子进程处理完毕,立即退出
}
int main() {
// 忽略 SIGCHLD,让子进程退出后由内核自动回收,避免僵尸进程
signal(SIGCHLD, SIG_IGN);
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(fd);
continue;
}
if (pid == 0) {
// 子进程:处理该连接,处理完退出
close(server_fd); // 子进程不再需要监听套接字
handle_client(fd);
}
// 父进程:关闭已交给子进程的连接 fd,继续 accept 下一个客户端
close(fd);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -o tcp_server_fork tcp_server_fork.c
./tcp_server_fork
与单线程 accept 循环的差异:
- 单线程模型:accept 后立即处理该连接的收发,处理完才 accept 下一个。若某个客户端长时间不发送数据,后续客户端会被阻塞在 accept 队列里,无法及时服务。
- fork 模型:父进程只负责 accept,每来一个连接就 fork 一个子进程去处理,父进程立刻回到 accept,因此多个客户端可以同时被服务,互不阻塞。
注意事项:
- 子进程要关闭 server_fd:子进程只处理自己的连接 fd,若不关闭监听套接字,会导致父进程无法正常关闭端口。
- 父进程要关闭 fd:父进程把连接 fd 交给子进程后应立即 close(fd),否则 fd 引用计数不为 0,连接不会真正关闭。
- 避免僵尸进程:子进程退出后父进程要 wait/waitpid 回收,或用 signal(SIGCHLD, SIG_IGN) 让内核自动回收。
- fork 开销较大:每来一个连接就 fork 一次,进程数会随并发量增长,适合连接数不多的场景;高并发建议用线程池或 I/O 多路复用。
示例二:使用 pthread 实现并发服务器 tcp_server_thread.c
与 fork 版本不同,这里为每个 accept 返回的连接 fd 创建一个独立线程来处理收发,主循环继续 accept 新连接。线程共享同一进程地址空间,创建开销比 fork 更小。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 线程函数:处理单个客户端的收发(回显)
void *handle_client(void *arg) {
int fd = *(int *)arg;
free(arg); // 释放传入的 fd 副本
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// 注意:send 也可能只发送部分数据,生产代码应循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
close(fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
// 为每个连接创建一个独立线程处理收发
pthread_t tid;
int *pfd = (int *)malloc(sizeof(int));
if (pfd == NULL) {
perror("malloc");
close(fd);
continue;
}
*pfd = fd;
if (pthread_create(&tid, NULL, handle_client, pfd) != 0) {
perror("pthread_create");
close(fd);
free(pfd);
continue;
}
// 分离线程:线程结束后自动回收资源,无需 join
pthread_detach(tid);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -pthread -o tcp_server_thread tcp_server_thread.c
./tcp_server_thread
与 fork 版本的对比:
- 线程共享地址空间:所有线程属于同一个进程,共享全局变量、堆内存和文件描述符表,通信无需进程间通信(IPC)机制;而 fork 出的子进程拥有独立的地址空间,共享数据需要 mmap、管道等额外手段。
- 创建开销更小:pthread_create 只创建线程控制块并分配栈,比 fork 复制整个进程地址空间快得多,适合连接数较多的场景。
- 需注意线程安全:线程共享全局变量,若多个线程同时读写同一数据(如计数器、日志缓冲区)必须加锁保护,否则会产生竞态条件;fork 版本因地址空间隔离,天然不存在这类共享冲突。
- 线程函数要 detach 或 join:这里用 pthread_detach 让线程结束后自动回收资源,避免线程资源泄漏;若用 join 则需保存每个线程 id 并逐一回收。
- 传入参数要小心:这里把 fd 拷贝到堆上再传给线程,避免多个线程共享同一个栈变量导致数据竞争;线程函数内用完即 free。
- send 也要循环:send 和 recv 一样可能只发送部分数据,生产代码应循环发送直到全部发完,或处理 EINTR 中断错误。
与 tcp_client.c 的联调测试步骤
gcc -Wall -g -o tcp_server_fork tcp_server_fork.c
gcc -Wall -g -o tcp_client tcp_client.c
./tcp_server_fork
./tcp_client 127.0.0.1
验证并发效果:在每个客户端中输入文本并回车,服务器会立即回显。由于每个连接由独立的子进程/线程处理,多个客户端可以同时收发数据,互不阻塞。例如在终端 2 输入 hello from client 1,在终端 3 输入 hello from client 2,两个客户端都能同时收到各自的回显。
观察服务器输出:服务器会打印每个客户端的 IP 和端口,例如:
listening on port 8080…
client 127.0.0.1:52341
client 127.0.0.1:52342
./tcp_server
另一个终端
示例:基于 fork 的多客户端并发服务器 tcp_server_concurrent.c
上面的 tcp_server.c 是单线程模型:accept 之后在同一个循环里处理当前连接的收发,处理完一个客户端才会 accept 下一个,因此同一时刻只能服务一个客户端。要支持多客户端并发,常用两种方式:fork 出子进程,或为每个连接创建 pthread 线程。下面先给出基于 fork 的版本。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 子进程处理函数:循环收发数据(回显)
void handle_client(int fd) {
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// 注意:send 也可能只发送部分数据,生产代码应循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
close(fd);
_exit(0); // 子进程处理完毕,直接退出,不执行父进程的清理代码
}
int main() {
// 忽略 SIGCHLD,让子进程退出后由内核自动回收,避免僵尸进程
signal(SIGCHLD, SIG_IGN);
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(fd);
continue;
}
if (pid == 0) {
// 子进程:处理该连接,处理完退出
close(server_fd); // 子进程不再需要监听套接字
handle_client(fd);
}
// 父进程:关闭已交给子进程的连接 fd,继续 accept 下一个客户端
close(fd);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -o tcp_server_concurrent tcp_server_concurrent.c
./tcp_server_concurrent
与单线程 accept 循环的差异:
- 单线程模型:accept 后立即处理该连接的收发,处理完才 accept 下一个。若某个客户端长时间不发送数据,后续客户端会被阻塞在 accept 队列里,无法及时服务。
- fork 模型:父进程只负责 accept,每来一个连接就 fork 一个子进程去处理,父进程立刻回到 accept,因此多个客户端可以同时被服务,互不阻塞。
- 线程模型:与 fork 类似,但用 pthread_create 为每个连接创建线程,共享进程地址空间,创建开销更小;缺点是线程间共享全局变量,需要加锁保护。
注意事项:
- 子进程要关闭 server_fd:子进程只处理自己的连接 fd,若不关闭监听套接字,会导致父进程无法正常关闭端口。
- 父进程要关闭 fd:父进程把连接 fd 交给子进程后应立即 close(fd),否则 fd 引用计数不为 0,连接不会真正关闭。
- 避免僵尸进程:子进程退出后父进程要 wait/waitpid 回收,或用 signal(SIGCHLD, SIG_IGN) 让内核自动回收。
- fork 开销较大:每来一个连接就 fork 一次,进程数会随并发量增长,适合连接数不多的场景;高并发建议用线程池或 I/O 多路复用(select/poll/epoll)。
上面的 tcp_server.c 是单线程模型:accept 之后在同一个循环里处理当前连接的收发,处理完一个客户端才会 accept 下一个,因此同一时刻只能服务一个客户端。要支持多客户端并发,常用两种方式:fork 出子进程,或为每个连接创建 pthread 线程。
示例:使用 fork 实现并发服务器
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
void handle_client(int fd) {
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
send(fd, buf, n, 0); // 回显
}
close(fd);
_exit(0);
}
int main() {
// 忽略 SIGCHLD,让子进程退出后由内核自动回收,避免僵尸进程
signal(SIGCHLD, SIG_IGN);
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(fd);
continue;
}
if (pid == 0) {
// 子进程:处理该连接,处理完退出
close(server_fd); // 子进程不再需要监听套接字
handle_client(fd);
}
// 父进程:关闭已交给子进程的连接 fd,继续 accept 下一个客户端
close(fd);
}
close(server_fd);
return 0;
}
编译运行:
bash
gcc -Wall -g -o tcp_server_fork tcp_server_fork.c
./tcp_server_fork
多个终端分别运行
./tcp_client 127.0.0.1
./tcp_client 127.0.0.1
与单线程 accept 循环的差异:
• 单线程模型:accept 后立即处理该连接的收发,处理完才 accept 下一个。若某个客户端长时间不发送数据,后续客户端会被阻塞在 accept 队列里,无法及时服务。
• fork 模型:父进程只负责 accept,每来一个连接就 fork 一个子进程去处理,父进程立刻回到 accept,因此多个客户端可以同时被服务,互不阻塞。
• 线程模型:与 fork 类似,但用 pthread_create 为每个连接创建线程,共享进程地址空间,创建开销更小;缺点是线程间共享全局变量,需要加锁保护。
注意事项:
• 子进程要关闭 server_fd:子进程只处理自己的连接 fd,若不关闭监听套接字,会导致父进程无法正常关闭端口。
• 父进程要关闭 fd:父进程把连接 fd 交给子进程后应立即 close(fd),否则 fd 引用计数不为 0,连接不会真正关闭。
•避免僵尸进程:子进程退出后父进程要 wait/waitpid 回收,或用 signal(SIGCHLD, SIG_IGN) 让内核自动回收。
•fork 开销较大:每来一个连接就 fork 一次,进程数会随并发量增长,适合连接数不多的场景;高并发建议用线程池或 I/O 多路复用(select/poll/epoll)。
示例:使用 pthread 实现并发服务器 tcp_server_thread.c
与 fork 版本不同,这里为每个 accept 返回的连接 fd 创建一个独立线程来处理收发,主循环继续 accept 新连接。线程共享同一进程地址空间,创建开销比 fork 更小。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 线程函数:处理单个客户端的收发(回显)
void *handle_client(void *arg) {
int fd = *(int *)arg;
free(arg); // 释放传入的 fd 副本
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
send(fd, buf, n, 0); // 回显
}
close(fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror(“socket”);
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
// 为每个连接创建一个独立线程处理收发
pthread_t tid;
int *pfd = (int *)malloc(sizeof(int));
*pfd = fd;
if (pthread_create(&tid, NULL, handle_client, pfd) != 0) {
perror("pthread_create");
close(fd);
free(pfd);
continue;
}
// 分离线程:线程结束后自动回收资源,无需 join
pthread_detach(tid);
}
close(server_fd);
return 0;
}
编译运行:
bash
gcc -Wall -g -pthread -o tcp_server_thread tcp_server_thread.c
./tcp_server_thread
多个终端分别运行
./tcp_client 127.0.0.1
./tcp_client 127.0.0.1
与 fork 版本的对比:
•线程共享地址空间:所有线程属于同一个进程,共享全局变量、堆内存和文件描述符表,通信无需进程间通信(IPC)机制;而 fork 出的子进程拥有独立的地址空间,共享数据需要 mmap、管道等额外手段。
•创建开销更小:pthread_create 只创建线程控制块并分配栈,比 fork 复制整个进程地址空间快得多,适合连接数较多的场景。
•需注意线程安全:线程共享全局变量,若多个线程同时读写同一数据(如计数器、日志缓冲区)必须加锁保护,否则会产生竞态条件;fork 版本因地址空间隔离,天然不存在这类共享冲突。
•线程函数要 detach 或 join:这里用 pthread_detach 让线程结束后自动回收资源,避免线程资源泄漏;若用 join 则需保存每个线程 ID 并逐一回收。
•传入参数要小心:这里把 fd 拷贝到堆上再传给线程,避免多个线程共享同一个栈变量导致数据竞争;线程函数内用完即 free。
./tcp_client 127.0.0.1
注意:网络编程中要处理字节序:htons/htonl/ntohs/ntohl。send/recv 也可能只发送/接收部分数据,生产代码需要循环处理。
注意:网络编程中要处理字节序:htons/htonl/ntohs/ntohl。send/recv 也可能只发送/接收部分数据,生产代码需要循环处理。
进阶示例:支持多客户端并发处理的 TCP 回显服务器
上面的 tcp_server.c 是单线程模型:accept 之后在同一个循环里处理当前连接的收发,处理完一个客户端才会 accept 下一个,因此同一时刻只能服务一个客户端。要支持多客户端并发,常用两种方式:fork 出子进程,或为每个连接创建 pthread 线程。下面分别给出两种实现,并对比其优缺点。
方式一:使用 fork 实现并发服务器 tcp_server_fork.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 子进程处理函数:循环收发数据(回显)
void handle_client(int fd) {
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// 注意:send 也可能只发送部分数据,生产代码应循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
close(fd);
_exit(0); // 子进程处理完毕,直接退出,不执行父进程的清理代码
}
int main() {
// 忽略 SIGCHLD,让子进程退出后由内核自动回收,避免僵尸进程
signal(SIGCHLD, SIG_IGN);
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
pid_t pid = fork();
if (pid < 0) {
perror("fork");
close(fd);
continue;
}
if (pid == 0) {
// 子进程:处理该连接,处理完退出
close(server_fd); // 子进程不再需要监听套接字
handle_client(fd);
}
// 父进程:关闭已交给子进程的连接 fd,继续 accept 下一个客户端
close(fd);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -o tcp_server_fork tcp_server_fork.c
./tcp_server_fork
方式二:使用 pthread 实现并发服务器 tcp_server_thread.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define PORT 8080
#define BUF_SIZE 1024
// 线程函数:处理单个客户端的收发(回显)
void *handle_client(void *arg) {
int fd = *(int *)arg;
free(arg); // 释放传入的 fd 副本
char buf[BUF_SIZE];
ssize_t n;
while ((n = recv(fd, buf, sizeof(buf), 0)) > 0) {
// 注意:send 也可能只发送部分数据,生产代码应循环发送
ssize_t sent = 0;
while (sent < n) {
ssize_t w = send(fd, buf + sent, n – sent, 0);
if (w < 0) {
perror("send");
break;
}
sent += w;
}
}
if (n < 0) {
perror("recv");
}
close(fd);
return NULL;
}
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
return 1;
}
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
return 1;
}
if (listen(server_fd, 10) < 0) {
perror("listen");
return 1;
}
printf("listening on port %d…\\n", PORT);
while (1) {
struct sockaddr_in cli;
socklen_t len = sizeof(cli);
int fd = accept(server_fd, (struct sockaddr *)&cli, &len);
if (fd < 0) {
perror("accept");
continue;
}
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &cli.sin_addr, ip, sizeof(ip));
printf("client %s:%d\\n", ip, ntohs(cli.sin_port));
// 为每个连接创建一个独立线程处理收发
pthread_t tid;
int *pfd = (int *)malloc(sizeof(int));
*pfd = fd;
if (pthread_create(&tid, NULL, handle_client, pfd) != 0) {
perror("pthread_create");
close(fd);
free(pfd);
continue;
}
// 分离线程:线程结束后自动回收资源,无需 join
pthread_detach(tid);
}
close(server_fd);
return 0;
}
编译运行:
gcc -Wall -g -pthread -o tcp_server_thread tcp_server_thread.c
./tcp_server_thread
两种方式的优缺点对比
| 地址空间 | 子进程拥有独立的地址空间,数据隔离性好,互不干扰 | 线程共享同一进程地址空间,全局变量、堆内存、文件描述符表共享 |
| 创建开销 | fork 需要复制整个进程地址空间(写时复制),开销较大 | pthread_create 只创建线程控制块并分配栈,开销远小于 fork |
| 数据共享 | 子进程间共享数据需要 mmap、管道、共享内存等 IPC 机制,较繁琐 | 天然共享全局变量和堆内存,通信方便,但需加锁保护避免竞态 |
| 健壮性 | 某个子进程崩溃(如段错误)不影响其他子进程和父进程 | 某个线程崩溃可能导致整个进程崩溃,影响所有连接 |
| 资源回收 | 需 wait/waitpid 回收,或 signal(SIGCHLD, SIG_IGN) 让内核自动回收 | 用 pthread_detach 分离线程,结束后自动回收;或 pthread_join 手动回收 |
| 适用场景 | 连接数不多、需要强隔离、或子进程需要独立环境的场景 | 连接数较多、需要频繁创建/销毁、共享数据较多的场景 |
关键注意事项:
- 子进程要关闭 server_fd:fork 后子进程只处理自己的连接 fd,若不关闭监听套接字,会导致父进程无法正常关闭端口。
- 父进程要关闭 fd:父进程把连接 fd 交给子进程/线程后应立即 close(fd),否则 fd 引用计数不为 0,连接不会真正关闭。
- 线程参数要小心:把 fd 拷贝到堆上再传给线程,避免多个线程共享同一个栈变量导致数据竞争;线程函数内用完即 free。
- 高并发场景:每连接一进程/一线程的开销会随并发量线性增长,连接数很大时建议改用线程池或 I/O 多路复用(select/poll/epoll)。
四、Makefile 编写
Makefile 用来描述编译规则,避免重复输入 gcc 命令。
示例 Makefile
makefile
CC := gcc
CFLAGS := -Wall -Wextra -g
TARGETS := copy fork_demo thread_demo tcp_server tcp_client
all: $(TARGETS)
四、Makefile 编写
Makefile 用来描述编译规则,避免重复输入 gcc 命令。
示例:支持多文件编译的 Makefile
大型项目通常由多个源文件组成,需要分别编译成目标文件再链接。下面是一个支持多文件编译的 Makefile 示例,包含变量定义、自动依赖生成和伪目标 clean。
# 变量定义
CC := gcc
CFLAGS := -Wall -Wextra -g
LDFLAGS :=
TARGET := app
SRCS := main.c util.c net.c
OBJS := $(SRCS:.c=.o) # main.o util.o net.o
DEPS := $(OBJS:.o=.d) # 自动生成的依赖文件
all: $(TARGET)
# 链接:由所有目标文件生成可执行文件
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) $^ -o $@
# 编译:由 .c 生成 .o,并生成依赖文件
%.o: %.c
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# 包含自动生成的依赖文件(若存在)
-include $(DEPS)
clean:
rm -f $(TARGET) $(OBJS) $(DEPS)
.PHONY: all clean
语法要点:
- 变量:CC、CFLAGS 等用 := 定义,$(CC) 引用变量值。SRCS 列出所有源文件,OBJS := $(SRCS:.c=.o) 用替换引用把 .c 后缀换成 .o,避免逐个手写目标文件。
- 自动依赖生成 -MMD -MP:-MMD 让 gcc 在编译时自动生成 .d 依赖文件,记录该 .c 包含的头文件;-MP 为每个头文件生成一个空规则,避免头文件被删除后 make 报错。-include $(DEPS) 把这些依赖文件包含进来,头文件一旦变化,make 会自动重编相关目标。
- 伪目标 clean:clean 不是真实文件,用 .PHONY 声明后,即使目录下存在同名文件,make clean 也会执行删除命令。
- 自动变量:$@ 表示目标名,$< 表示第一个依赖,$^ 表示所有依赖。链接时用 $^ 把全部 .o 传给 gcc。
- 模式规则:%.o: %.c 是一条模式规则,匹配任意 .c 到 .o 的编译,无需为每个文件单独写规则。
示例:使用 pthread 的 Makefile
线程程序编译时必须加 -pthread 标志,否则链接阶段会报 undefined reference to pthread_create。下面是一个同时编译普通程序和线程程序的 Makefile:
CC := gcc
CFLAGS := -Wall -Wextra -g
TARGETS := copy tcp_server tcp_client mutex_demo thread_pool
all: $(TARGETS)
# 通用规则:由 .c 生成同名可执行文件
%: %.c
$(CC) $(CFLAGS) $< -o $@
# 线程程序需要额外链接 pthread
mutex_demo: mutex_demo.c
$(CC) $(CFLAGS) -pthread $< -o $@
thread_pool: thread_pool.c
$(CC) $(CFLAGS) -pthread $< -o $@
clean:
rm -f $(TARGETS)
.PHONY: all clean
要点:
- 普通程序(如 copy、tcp_server)走通用规则 %: %.c,不需要 -pthread。
- 线程程序(如 mutex_demo、thread_pool)单独写规则,在 $(CFLAGS) 后追加 -pthread。-pthread 同时影响预处理和链接:编译时定义 _REENTRANT 等宏,链接时自动链接 libpthread。
- 若漏加 -pthread,编译可能通过,但链接时报错:undefined reference to 'pthread_create'。
使用:
make # 编译所有目标
make mutex_demo # 只编译 mutex_demo
make clean # 清理
示例:统一管理所有示例的 Makefile
下面是一个完整的 Makefile,用于统一编译管理本文中出现的所有示例程序:copy、fork_demo、thread_demo、mutex_demo、thread_pool、tcp_server、tcp_client。它综合运用了变量定义、模式规则、自动依赖生成(-MMD -MP)和 clean 伪目标。
# ============ 变量定义 ============
CC := gcc
CFLAGS := -Wall -Wextra -g
LDFLAGS :=
# 普通程序(无需额外链接库)
COMMON_TARGETS := copy fork_demo tcp_server tcp_client
# 线程程序(需要链接 pthread)
THREAD_TARGETS := thread_demo mutex_demo thread_pool
# 全部目标
TARGETS := $(COMMON_TARGETS) $(THREAD_TARGETS)
# 源文件与目标文件、依赖文件
SRCS := $(addsuffix .c, $(TARGETS))
OBJS := $(SRCS:.c=.o)
DEPS := $(OBJS:.o=.d)
# ============ 默认目标 ============
all: $(TARGETS)
# ============ 通用规则:由 .c 生成同名可执行文件 ============
%: %.c
$(CC) $(CFLAGS) $< -o $@
# ============ 线程程序需要额外链接 pthread ============
thread_demo: thread_demo.c
$(CC) $(CFLAGS) -pthread $< -o $@
mutex_demo: mutex_demo.c
$(CC) $(CFLAGS) -pthread $< -o $@
thread_pool: thread_pool.c
$(CC) $(CFLAGS) -pthread $< -o $@
# ============ 自动依赖生成(-MMD -MP) ============
# 编译时生成 .d 依赖文件,记录头文件依赖;头文件变化时自动重编
%.o: %.c
$(CC) $(CFLAGS) -MMD -MP -c $< -o $@
# 包含自动生成的依赖文件(若存在)
-include $(DEPS)
# ============ 清理 ============
clean:
rm -f $(TARGETS) $(OBJS) $(DEPS)
.PHONY: all clean
说明:
- 变量定义:CC、CFLAGS 等用 := 定义,$(CC) 引用变量值。COMMON_TARGETS 和 THREAD_TARGETS 分别列出普通程序和线程程序,TARGETS 合并两者,避免重复书写。
- 模式规则:%: %.c 是一条模式规则,匹配任意 .c 到同名可执行文件的编译,普通程序(如 copy、tcp_server)自动走这条规则,无需为每个文件单独写规则。
- 线程程序单独规则:thread_demo、mutex_demo、thread_pool 需要 -pthread 标志,因此单独写规则,在 $(CFLAGS) 后追加 -pthread。-pthread 同时影响预处理和链接:编译时定义 _REENTRANT 等宏,链接时自动链接 libpthread。
- 自动依赖生成 -MMD -MP:-MMD 让 gcc 在编译时自动生成 .d 依赖文件,记录该 .c 包含的头文件;-MP 为每个头文件生成一个空规则,避免头文件被删除后 make 报错。-include $(DEPS) 把这些依赖文件包含进来,头文件一旦变化,make 会自动重编相关目标。
- 伪目标 clean:clean 不是真实文件,用 .PHONY 声明后,即使目录下存在同名文件,make clean 也会执行删除命令。
- 自动变量:$@ 表示目标名,$< 表示第一个依赖。链接时用 $^ 把全部 .o 传给 gcc。
使用方式:
make # 编译所有目标(copy fork_demo thread_demo mutex_demo thread_pool tcp_server tcp_client)
make copy # 只编译 copy
make tcp_server # 只编译 tcp_server
make clean # 清理所有可执行文件、目标文件和依赖文件
验证示例:
# 编译全部
make
# 运行普通程序
./copy a.txt b.txt
./fork_demo
# 运行线程程序
./thread_demo
./mutex_demo
./thread_pool
# 运行网络程序(先启动服务器,再启动客户端)
./tcp_server
./tcp_client 127.0.0.1
# 清理
make clean
要点:
- Makefile 命令行前必须是 Tab,不是空格。
- $@ 表示目标名,$< 表示第一个依赖。
- .PHONY 声明伪目标,避免和同名文件冲突。
- 变量用 := 或 = 定义。
- 大型项目可以用 wildcard、patsubst、自动依赖生成 -MMD -MP。
通用规则:由 .c 生成同名可执行文件
%: %.c
$(CC) $(CFLAGS) $< -o $@
线程程序需要额外链接 pthread
thread_demo: thread_demo.c
$(CC) $(CFLAGS) -pthread $< -o $@
clean:
rm -f $(TARGETS)
.PHONY: all clean
使用:
make # 编译所有目标
make copy # 只编译 copy
make clean # 清理
要点:
•Makefile 命令行前必须是 Tab,不是空格。
•@表示目标名,@ 表示目标名,@表示目标名,< 表示第一个依赖。
•.PHONY 声明伪目标,避免和同名文件冲突。
•变量用 := 或 = 定义。
•大型项目可以用 wildcard、patsubst、自动依赖生成 -MMD -MP。
学习建议
man 2 open
man 2 fork
man 3 pthread_create
man 2 socket
strace ./copy a.txt b.txt
gcc -g -o demo demo.c
gdb ./demo
- 《UNIX 环境高级编程》APUE
- 《UNIX 网络编程》UNP
- 《Linux 程序设计》
掌握顺序建议:文件 I/O → 进程 → 线程 → Socket → Makefile/调试工具。先把每个示例跑通通,再逐步加入错误处理、循环读写、并发连接、线程池、I/O 多路复用等进阶内容。
总结
本文覆盖了 Linux 系统编程的四个核心主题,要点如下:
- 文件描述符与系统调用:Linux 中“一切皆文件”,文件 I/O 通过 open/read/write/close 等系统调用完成,文件描述符 0/1/2 分别对应标准输入、标准输出和标准错误;read/write 可能返回少于请求的字节数,写操作需循环处理。
- fork/exec/wait 与 pthread 对比:fork 创建独立地址空间的子进程,配合 exec* 替换进程映像、wait 回收避免僵尸进程;pthread_create 创建共享地址空间的线程,创建开销更小,但共享全局变量需用互斥锁保护,避免竞态条件。
- TCP 编程流程:服务器遵循 socket -> bind -> listen -> accept -> recv/send -> close,客户端遵循 socket -> connect -> send/recv -> close;每一步都要检查返回值并处理错误,多客户端并发可用 fork 或 pthread 实现。
- Makefile 作用:Makefile 用规则描述编译过程,避免重复输入 gcc 命令;通过变量、模式规则、自动依赖生成(-MMD -MP)和伪目标 clean 实现高效、可维护的构建;线程程序需额外加 -pthread 标志。
进一步学习建议
- I/O 多路复用:当并发连接数很大时,fork/pthread 每连接一线程的开销过高,可学习 select/poll/epoll,用单线程同时监听大量 fd,是高性能网络服务器的常用方案。
- 线程池优化:本文的线程池示例已展示任务队列 + 条件变量的基本模型,可进一步优化:动态调整线程数、任务优先级、无锁队列(如 CAS)等,以提升吞吐量。
- gdb 调试:用 gcc -g 编译后,可用 gdb 设置断点、单步跟踪、查看变量,定位段错误和逻辑错误;配合 strace 观察系统调用,能更直观地理解程序行为。
经典参考书籍与文档
- 《UNIX 环境高级编程》(APUE):系统编程的“圣经”,系统调用、进程/线程、文件 I/O 的权威参考。
- 《UNIX 网络编程》(UNP):Socket 编程的经典之作,深入讲解 TCP/IP 编程模型与并发服务器设计。
- 《Linux 程序设计》:面向 Linux 应用开发的入门经典,覆盖文件、进程、线程、网络与 Makefile 等主题。
- man 手册:Linux 自带的最权威参考,man 2 open、man 2 fork、man 3 pthread_create、man 2 socket 等随时查阅。
- GNU Make 手册:官方文档,系统讲解 Makefile 语法、变量、模式规则与自动依赖生成,是编写复杂构建脚本的权威指南。
掌握顺序建议:文件 I/O → 进程 → 线程 → Socket → Makefile/调试工具。先把每个示例跑通,再逐步加入错误处理、循环读写、并发连接、线程池、I/O 多路复用等进阶内容。


