跳到主要内容Linux TCP 服务器开发:从 Echo 到远程命令执行的并发与安全 | 极客日志C
Linux TCP 服务器开发:从 Echo 到远程命令执行的并发与安全
介绍 Linux 下 TCP 服务器开发流程。从基础 Socket 编程实现 Echo 服务器入手,逐步优化并发能力,涵盖多进程、多线程及线程池模型。重点讲解文件描述符管理、僵尸进程处理及线程同步机制。最后扩展至远程命令执行场景,通过白名单机制防范安全风险。适合后端开发者学习网络编程与高并发架构。
ServerBase22 浏览 一、基础篇:实现一个能跑的 echo 服务器
echo 服务器的核心逻辑很简单 —— 客户端发什么,服务器就返回什么。这一步我们先搞定基础的 socket 编程流程,以及开发中最容易踩的绑定、连接问题。
1.1 先解决两个核心问题:绑定与连接
在编写服务器代码前,先理清两个高频问题:bind绑定和connect连接参数。
服务器端调用bind函数时,核心是绑定 IP 和端口:
- 如果绑定
0.0.0.0,表示监听服务器所有网卡的该端口,这是服务器的常规操作;
- 如果绑定具体 IP(如
192.168.1.100),则只监听该网卡的请求,适合多网卡的定向监听场景;
- 绑定失败常见原因:端口被占用、权限不足(1024 以下端口需要 root)。
客户端的connect函数核心是指定要连接的服务器信息:
- 参数必须是服务器的
struct sockaddr_in结构体,包含服务器 IP 和端口;
- 客户端无需手动
bind本地端口,系统会自动分配一个未使用的端口,这是客户端和服务器的核心区别之一。
给大家贴一段客户端connect的核心代码,一看就懂:
1.2 echo 客户端与服务器完整实现
理清核心问题后,直接上 echo 客户端和服务器的核心代码,结合图片里的逻辑拆解。
echo 客户端核心流程:创建 socket → 连接服务器 → 发送数据 → 接收返回数据 → 关闭 socket。
核心代码片段:
#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 SERVER_IP
{
client_sock = socket(AF_INET, SOCK_STREAM, );
(client_sock == ) {
perror();
;
}
(&server_addr, , (server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);
(connect(client_sock, ( sockaddr*)&server_addr, (server_addr)) == ) {
perror();
close(client_sock);
;
}
buf[BUF_SIZE] = ;
write(client_sock, buf, (buf));
(buf, , BUF_SIZE);
read(client_sock, buf, BUF_SIZE);
(, buf);
close(client_sock);
;
}
"127.0.0.1"
#define SERVER_PORT 8080
#define BUF_SIZE 1024
int
main
()
int
0
if
-1
"socket failed"
return
-1
struct sockaddr_in server_addr;
memset
0
sizeof
if
struct
sizeof
-1
"connect failed"
return
-1
char
"Hello Echo Server!"
strlen
memset
0
printf
"Received from server: %s\n"
return
0
echo 服务器核心流程:创建 socket → 绑定 IP 端口 → 监听 → 接受连接 → 读取数据 → 原样返回 → 关闭连接。
#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 SERVER_PORT 8080
#define BUF_SIZE 1024
int main() {
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
if (listen_sock == -1) {
perror("socket failed");
return -1;
}
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(listen_sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("bind failed");
close(listen_sock);
return -1;
}
if (listen(listen_sock, 5) == -1) {
perror("listen failed");
close(listen_sock);
return -1;
}
printf("Echo server listening on 0.0.0.0:%d\n", SERVER_PORT);
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int conn_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &client_addr_len);
if (conn_sock == -1) {
perror("accept failed");
close(listen_sock);
return -1;
}
printf("Client connected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
char buf[BUF_SIZE];
ssize_t n = read(conn_sock, buf, BUF_SIZE);
if (n > 0) {
write(conn_sock, buf, n);
}
close(conn_sock);
close(listen_sock);
return 0;
}
二、并发篇 1:多进程版本 echo 服务器
上面的基础版服务器有个致命问题 —— 只能处理一个客户端连接,处理完就退出了。要支持并发,首先想到用多进程:父进程负责接受连接,子进程负责处理业务。
2.1 多进程核心逻辑与代码
- 父进程一直循环调用
accept,拿到新的连接套接字conn_sock;
- 每次拿到
conn_sock后,fork()创建子进程;
- 子进程不需要监听套接字
listen_sock,要主动关闭(避免文件描述符泄漏);
- 子进程专注处理
conn_sock的 echo 业务,处理完关闭conn_sock并退出。
优化后的多进程服务器核心代码(只改 accept 后的逻辑):
while (1) {
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int conn_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &client_addr_len);
if (conn_sock == -1) {
perror("accept failed");
continue;
}
printf("Client connected: %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
close(conn_sock);
continue;
} else if (pid == 0) {
close(listen_sock);
char buf[BUF_SIZE];
ssize_t n = read(conn_sock, buf, BUF_SIZE);
if (n > 0) {
write(conn_sock, buf, n);
}
close(conn_sock);
exit(0);
} else {
close(conn_sock);
}
}
2.2 解决僵尸进程问题
多进程模型有个坑:子进程退出后,如果父进程不回收,会变成僵尸进程,占用系统资源。
方案 1:注册信号处理函数(推荐)
子进程退出时会给父进程发SIGCHLD信号,我们可以注册信号处理函数,用waitpid非阻塞回收子进程:
#include <signal.h>
#include <sys/wait.h>
void handle_sigchld(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
signal(SIGCHLD, handle_sigchld);
方案 2:创建孙子进程(孤儿进程)
- 父进程 fork 出子进程;
- 子进程再 fork 出孙子进程,然后子进程立即退出;
- 孙子进程变成孤儿进程,由系统(init/systemd)接管,退出后自动回收;
- 父进程不需要阻塞,也不用处理信号。
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
close(conn_sock);
continue;
} else if (pid == 0) {
pid_t grand_pid = fork();
if (grand_pid == -1) {
exit(1);
} else if (grand_pid == 0) {
close(listen_sock);
char buf[BUF_SIZE];
ssize_t n = read(conn_sock, buf, BUF_SIZE);
if (n > 0) write(conn_sock, buf, n);
close(conn_sock);
exit(0);
} else {
exit(0);
}
} else {
close(conn_sock);
}
2.3 多进程模型的小细节:获取客户端 IP
通过accept的第二个参数client_addr,可以轻松获取客户端的 IP 和端口:
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, INET_ADDRSTRLEN);
uint16_t client_port = ntohs(client_addr.sin_port);
printf("Client connected: %s:%d\n", client_ip, client_port);
2.4 多进程模型的痛点
- 进程创建和销毁的开销大;
- 每个进程占用独立的内存空间,连接数多了会导致服务器内存、CPU 压力剧增;
- 进程间通信复杂(如果需要共享数据)。
三、并发篇 2:多线程版本服务器
线程是轻量级进程,创建 / 销毁开销远小于进程,且线程间共享进程资源(如文件描述符、内存),适合高并发场景。
3.1 多线程核心逻辑与代码
- 父进程(主线程)循环
accept获取conn_sock;
- 每次拿到
conn_sock后,创建新线程处理业务;
- 线程处理完业务后关闭
conn_sock并退出。
#include <pthread.h>
void* echo_handler(void* arg) {
int conn_sock = *(int*)arg;
free(arg);
pthread_detach(pthread_self());
char buf[BUF_SIZE];
ssize_t n = read(conn_sock, buf, BUF_SIZE);
if (n > 0) {
write(conn_sock, buf, n);
}
close(conn_sock);
return NULL;
}
while (1) {
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int conn_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &client_addr_len);
if (conn_sock == -1) {
perror("accept failed");
continue;
}
int* p_conn_sock = malloc(sizeof(int));
*p_conn_sock = conn_sock;
pthread_t tid;
if (pthread_create(&tid, NULL, echo_handler, p_conn_sock) != 0) {
perror("pthread_create failed");
free(p_conn_sock);
close(conn_sock);
}
}
3.2 线程与文件描述符的两个关键问题
问题 1:进程打开的 FD,线程能看到吗?(答案:1)
文件描述符(FD)是进程级资源,所有线程共享同一个进程的 FD 表。比如主线程打开的listen_sock,所有子线程都能看到;子线程创建的conn_sock,主线程也能操作。
问题 2:线程敢不敢关闭自己不需要的 FD?(答案:敢,但要谨慎)
- 可以关闭:比如子线程不需要
listen_sock,可以关闭,不影响主线程的listen_sock(FD 的引用计数减 1,只有引用计数为 0 时才真正关闭);
- 谨慎点:不要关闭其他线程还在使用的 FD!比如主线程还在处理
conn_sock,子线程如果关闭这个 FD,会导致主线程的 IO 操作失败。
3.3 短服务 vs 长服务
- 短服务:单次请求 - 响应就断开(如 echo 服务器),线程创建 / 销毁的开销可以接受;
- 长服务:连接保持,持续交互(如抢票、翻译服务),线程会长期存在,更能体现线程的优势。
四、并发篇 3:线程池版本服务器
多线程虽然比多进程高效,但如果连接数暴增(比如上万),频繁创建 / 销毁线程的开销依然很大。线程池就是提前创建一批线程,放在池子里,有连接就分配线程处理,处理完线程回到池子,避免频繁创建销毁。
4.1 线程池核心设计
线程池的核心是'任务队列 + 互斥锁 + 条件变量':
- 提前创建 N 个线程,线程启动后阻塞在条件变量上;
- 主线程
accept拿到conn_sock后,把conn_sock封装成任务,加入任务队列;
- 发送条件变量信号,唤醒一个空闲线程处理任务;
- 线程处理完任务后,回到阻塞状态,等待下一个任务。
4.2 线程池核心代码(简化版)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define THREAD_POOL_SIZE 4
#define BUF_SIZE 1024
#define SERVER_PORT 8080
typedef struct {
int conn_sock;
} Task;
Task task_queue[1024];
int queue_head = 0;
int queue_tail = 0;
pthread_mutex_t queue_mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t queue_cond = PTHREAD_COND_INITIALIZER;
void* thread_worker(void* arg) {
while (1) {
pthread_mutex_lock(&queue_mutex);
while (queue_head == queue_tail) {
pthread_cond_wait(&queue_cond, &queue_mutex);
}
Task task = task_queue[queue_head];
queue_head = (queue_head + 1) % 1024;
pthread_mutex_unlock(&queue_mutex);
char buf[BUF_SIZE];
ssize_t n = read(task.conn_sock, buf, BUF_SIZE);
if (n > 0) {
write(task.conn_sock, buf, n);
}
close(task.conn_sock);
}
return NULL;
}
void init_thread_pool() {
pthread_t tid;
for (int i = 0; i < THREAD_POOL_SIZE; i++) {
pthread_create(&tid, NULL, thread_worker, NULL);
pthread_detach(tid);
}
}
void add_task(int conn_sock) {
pthread_mutex_lock(&queue_mutex);
task_queue[queue_tail].conn_sock = conn_sock;
queue_tail = (queue_tail + 1) % 1024;
pthread_cond_signal(&queue_cond);
pthread_mutex_unlock(&queue_mutex);
}
int main() {
init_thread_pool();
int listen_sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
bind(listen_sock, (struct sockaddr*)&server_addr, sizeof(server_addr));
listen(listen_sock, 5);
while (1) {
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int conn_sock = accept(listen_sock, (struct sockaddr*)&client_addr, &client_addr_len);
if (conn_sock == -1)
continue;
add_task(conn_sock);
}
close(listen_sock);
return 0;
}
- 主线程只负责'接连接',不处理业务;
- 线程池只负责'处理业务',不用关心连接怎么来;
- 上层业务逻辑(比如把 echo 改成翻译、抢票)只需要修改
thread_worker里的处理逻辑即可,不用动底层的连接逻辑。
五、扩展篇:远程命令执行服务器(附安全防护)
把 echo 的逻辑改成'客户端发命令,服务器执行并返回结果',是很常见的后端需求,但这里有巨大的安全风险,必须做防护。
5.1 远程命令执行核心逻辑
核心思路:客户端发送命令字符串(如ls、pwd),服务器用popen执行命令,读取执行结果并返回给客户端。
char buf[BUF_SIZE];
ssize_t n = read(task.conn_sock, buf, BUF_SIZE);
if (n > 0) {
buf[n-1] = '\0';
FILE* fp = popen(buf, "r");
if (fp == NULL) {
const char* err = "Command execute failed\n";
write(task.conn_sock, err, strlen(err));
close(task.conn_sock);
continue;
}
char result[BUF_SIZE];
while (fgets(result, BUF_SIZE, fp) != NULL) {
write(task.conn_sock, result, strlen(result));
}
pclose(fp);
}
close(task.conn_sock);
5.2 安全防护:白名单机制
如果直接执行客户端发来的命令,风险极大 —— 比如客户端发rm -rf /,服务器直接玩完。所以必须做白名单:只允许执行指定的安全命令。
5.3 白名单核心代码
const char* whitelist[] = {"ls", "pwd", "date", "whoami", NULL};
int is_in_whitelist(const char* cmd) {
for (int i = 0; whitelist[i] != NULL; i++) {
if (strcmp(cmd, whitelist[i]) == 0) {
return 1;
}
}
return 0;
}
if (n > 0) {
buf[n-1] = '\0';
if (!is_in_whitelist(buf)) {
const char* err = "Error: Command not allowed\n";
write(task.conn_sock, err, strlen(err));
close(task.conn_sock);
continue;
}
FILE* fp = popen(buf, "r");
}
六、总结
本文从基础的 echo 服务器出发,一步步优化并发能力(多进程→多线程→线程池),最后扩展到远程命令执行并补充安全防护,核心知识点总结:
- 基础 socket 编程:服务器
bind+listen+accept,客户端connect,注意 FD 的管理;
- 并发优化:多进程解决基础并发但开销大,多线程轻量化但需注意 FD 共享,线程池是高并发最优解;
- 安全防护:远程命令执行必须加白名单,限制可执行的命令范围,避免恶意攻击。
- 调试时用
netstat -anp | grep 端口查看端口监听状态,确认服务器是否正常启动;
- 用
ps -ef | grep 进程名查看进程 / 线程状态,排查僵尸进程、线程泄漏问题。
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online