最近在学习Linux应用层开发,学习了基于TCP的简易服务器的搭建,在这里和大家分享分享。
关键词:守护进程,TCP,进程和线程,系统调用,Makefile
目录
知识准备
- 守护进程:是一种在后台运行的进程,我们常听到的daemon进程就是守护进程,它通常不与用户直接交互,而是提供系统级的服务。不与用户直接交互,可以简单理解成用户通过终端看不到守护进程的输出信息,也无法通过终端去操作守护进程(除了kill指令结束进程)。
- TCP协议:传输控制协议,是因特网协议栈中一种面向连接的、可靠的传输层协议。它是保证数据从一个网络设备可靠地传输到另一个网络设备的重要协议。重点是:面向连接的,可靠的传输协议。TCP涉及到的知识点很多,这里就不展开了。
- 进程和线程:
- 进程是操作系统中资源分配的基本单位。它代表一个正在执行的程序实例,每个进程有自己独立的内存空间、系统资源(如文件描述符、句柄等)以及执行上下文。
- 线程是进程中的一个执行单元,也是CPU调度的基本单位。线程是共享同一进程内的资源(如内存、文件句柄等)的多个独立执行路径。线程可以理解成轻量级的进程。
- 系统调用:是用户程序与操作系统内核之间进行交互的重要接口。通过系统调用,程序可以请求内核执行特定的操作。常见的系统调用有:
- 文件操作:
open()
read()
write()
close()
等 - 进程管理:
fork()
waitpid()
getpid()
execve()
等 - 网络通信:
socket()
bind()
listen()
accept()
connect()
send()
recv()
- 文件操作:
- Makefile:是一种用于自动化构建过程的文件,通常在软件开发中使用。可以将Makefile理解为一种代码管理工具,用来指导
make
指令如何编译和链接程序的。
服务端(tcp_server)
服务端的搭建思路:
- 定义并初始化服务端地址,创建和绑定套接字;
- 监听并接收客户端的连接请求;
- 创建子进程与客户端进行通信,并对接收到的数据进行相应处理;
- 信号处理,处理僵尸进程、处理SIGTERM信号;
- 日志记录(syslog);
- 资源释放与处理。这点很容易被忘记,要多留心。
c
#include <sys/socket.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <pthread.h>
#include <unistd.h>
#include <signal.h>
#include <syslog.h>
#define error_handle(cmd, result) \
if (result < 0) \
{ \
syslog(LOG_ERR, "%s", cmd); \
return -1; \
}
void server_handler(void *argv); // 服务端数据逻辑处理函数
void zombie_handler(int sig); // 僵尸进程清理函数
void sigterm_handler(int sig); // 用于处理 SIGTERM 信号的函数
int socketfd;
int main(int argc, char const *argv[])
{
char log_buf[1024];
memset(log_buf, 0, 1024);
// 初始化地址结构体
struct sockaddr_in server_addr, client_addr;
memset(&server_addr, 0, sizeof(server_addr));
memset(&client_addr, 0, sizeof(client_addr));
// 配置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(6666);
// 创建套接字并绑定IP和端口
socketfd = socket(AF_INET, SOCK_STREAM, 0);
int temp_res = bind(socketfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
error_handle("bind", temp_res);
// 监听来自客户端的连接请求,最大监听队列长度为 128
temp_res = listen(socketfd, 128);
error_handle("listen", temp_res);
// 处理 SIGCHLD 信号,避免僵尸进程出现 SIGCHLD:用于通知父进程,子进程的状态发生了变化
signal(SIGCHLD, zombie_handler);
// 处理 SIGTERM 函数,优雅退出 SIGTERM:是一个请求进程终止的信号,运行kill <PID>指令就会发送SIGTERM信号
signal(SIGTERM, sigterm_handler);
while (1)
{
// 获取客户端地址结构体的大小
socklen_t clientaddr_len = sizeof(client_addr);
// 接收客户端的连接请求
int clientfd = accept(socketfd, (struct sockaddr *)&client_addr, &clientaddr_len);
pid_t pid = fork();
/*
fork() 系统调用会复制父进程的整个进程状态,包括打开的文件描述符、log_buf缓冲区等。
父进程:主要负责监听客户端连接请求,并接受连接。(不需要客户端文件描述符)
子进程:负责处理特定的客户端连接。(不需要socket套接字)
*/
if (pid > 0) // 父进程
{
sprintf(log_buf, "这是父进程,PID为%d\n", getpid());
syslog(LOG_INFO, "%s", log_buf);
memset(log_buf, 0, 1024);
// 父进程释放客户端文件描述符
close(clientfd);
}
else if (pid == 0) // 子进程
{
// 关闭监听套接字
close(socketfd);
sprintf(log_buf, "这是子进程,PID为%d,与客户端的:%s at port %d 文件描述符%d建立连接\n",
getpid(), inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), clientfd);
syslog(LOG_INFO, "%s", log_buf);
memset(log_buf, 0, 1024);
// 建立通信后的操作:
server_handler((void *)&clientfd);
close(clientfd);
exit(EXIT_SUCCESS);
}
}
return 0;
}
void server_handler(void *argv)
{
char log_buf[1024];
memset(log_buf, 0, 1024);
// 客户端文件描述符
int client_fd = *(int *)argv;
ssize_t read_count = 0, write_count = 0;
char *read_buf;
char *write_buf;
// 动态分配内存以存储接收和发送的数据
read_buf = malloc(sizeof(char) * 1024);
write_buf = malloc(sizeof(char) * 1024);
// recv如果没有接收到数据会阻塞等待;终端输入Ctrl+D,recv返回0
while ((read_count = recv(client_fd, read_buf, 1024, 0)))
{
if (read_count < 0)
{
syslog(LOG_ERR, "服务器接收错误\n");
exit(EXIT_FAILURE);
}
// 对接收到的数据进程处理
sprintf(log_buf, "服务端PID:%d接收到来自client_fd%d的内容:%s", getpid(), client_fd, read_buf);
syslog(LOG_INFO, "%s", log_buf);
memset(log_buf, 0, 1024);
// 向客户端发送响应信息
sprintf(write_buf, "服务端PID:%d收到信息!\n", getpid());
write_count = send(client_fd, write_buf, 1024, 0);
}
sprintf(log_buf, "服务端 pid: %d: 客户端 client_fd: %d 请求关闭连接......\n", getpid(), client_fd);
syslog(LOG_NOTICE, "%s", log_buf);
memset(log_buf, 0, 1024);
// 向客户端发送关闭连接的确认信息
sprintf(write_buf, "服务端 pid: %d: 收到了关闭连接的申请!\n", getpid());
write_count = send(client_fd, write_buf, 1024, 0);
// 关闭写时会自动发送一个 FIN 包给连接的对端,此时对端的recv调用将接收到 0。
shutdown(client_fd, SHUT_WR);
sprintf(log_buf, "服务端 pid: %d: 释放 client_fd: %d 资源\n", getpid(), client_fd);
syslog(LOG_NOTICE, "%s", log_buf);
close(client_fd);
free(read_buf);
free(write_buf);
}
void zombie_handler(int sig)
{
pid_t pid;
int status; // 存储子进程的退出状态
char buf[1024];
memset(buf, 0, 1024);
// WNOHANG表示非阻塞调用,如果没有任何子进程终止,立即返回0
// 如果一个子进程已经终止,waitpid() 会立即返回该子进程的PID
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
{
/*
WIFEXITED(status):判断子进程是否正常退出。如果正常退出,则可以使用 WEXITSTATUS(status) 获取其退出状态。
WIFSIGNALED(status):判断子进程是否因为未捕获的信号而终止。如果是,则可以使用 WTERMSIG(status) 获取导致终止的信号编号。
*/
if (WIFEXITED(status))
{
sprintf(buf, "子进程: %d 以 %d 状态正常退出,已被回收\n", pid, WEXITSTATUS(status));
syslog(LOG_INFO, "%s", buf);
}
else if (WIFSIGNALED(status))
{
sprintf(buf, "子进程: %d 被 %d 信号杀死,已被回收\n", pid, WTERMSIG(status));
syslog(LOG_INFO, "%s", buf);
}
else
{
sprintf(buf, "子进程: %d 因其它原因退出,已被回收\n", pid);
syslog(LOG_WARNING, "%s", buf);
}
}
}
void sigterm_handler(int sig)
{
syslog(LOG_NOTICE, "服务端接收到守护进程发出的 SIGTERM,准备退出...");
syslog(LOG_NOTICE, "释放 socketfd");
close(socketfd);
syslog(LOG_NOTICE, "释放 syslog 连接,服务端进程终止");
closelog(); // 关闭系统日志连接
exit(EXIT_SUCCESS);
}
客户端(tcp_client)
客户端的搭建思路:
- 初始化客户端,定义服务端地址,创建套接字连接服务端
- 创建读写线程处理通信数据
- 线程同步与资源释放
c
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>
#define handle_error(cmd, result) \
if (result < 0) \
{ \
perror(cmd); \
return -1; \
}
void *read_from_server(void *argv);
void *write_to_server(void *argv);
int main(int argc, char const *argv[])
{
// 声明客户端读写线程
pthread_t pid_read, pid_write;
int socketfd;
// 初始化服务端地址
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
// 配置服务端
server_addr.sin_family = AF_INET;
// 连接本机 127.0.0.1
server_addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
// 连接端口 6666
server_addr.sin_port = htons(6666);
/*
这里选择不配置客户端地址和端口,让操作系统自动分配
*/
// 创建 socket套接字
socketfd = socket(AF_INET, SOCK_STREAM, 0);
handle_error("socket", socketfd);
// 连接 server
int temp = connect(socketfd, (struct sockaddr *)&server_addr, sizeof(server_addr));
handle_error("connect", temp);
// 启动一个子线程,用来读取服务端数据,并打印到 stdout
pthread_create(&pid_read, NULL, read_from_server, (void *)&socketfd);
// 启动一个子线程,用来从命令行读取数据并发送到服务端
pthread_create(&pid_write, NULL, write_to_server, (void *)&socketfd);
// 主线程等待子线程退出
pthread_join(pid_read, NULL);
pthread_join(pid_write, NULL);
printf("关闭资源\n");
close(socketfd);
return 0;
}
void *read_from_server(void *argv)
{
int socketfd = *(int *)argv;
ssize_t read_count = 0;
// 初始化读缓冲区
char *read_buf;
read_buf = malloc(sizeof(char) * 1024);
while (read_count = recv(socketfd, read_buf, 1024, 0))
{
if (read_count < 0)
{
perror("recv");
exit(EXIT_FAILURE);
}
// 输出接收到的内容到终端
fputs(read_buf, stdout);
}
printf("client: 收到服务端的终止信号......\n");
// 释放缓冲区内存
free(read_buf);
}
void *write_to_server(void *argv)
{
int socketfd = *(int *)argv;
ssize_t writed_count;
// 初始化写(发送)缓冲区
char *write_buf;
write_buf = malloc(sizeof(char) * 1024);
while (fgets(write_buf, 1024, stdin) != NULL)
{
send(socketfd, write_buf, 1024, 0);
if (writed_count < 0)
{
perror("send");
exit(EXIT_FAILURE);
}
}
printf("client: 接收到命令行的终止信号,不再写入,关闭连接......\n");
shutdown(socketfd, SHUT_WR);
// 释放缓冲区内存
free(write_buf);
}
守护进程(daemon_test)
守护进程创建思路:
- 第一次fork,退出父进程,子进程创建新会话;第二次fork,退出父进程(第一次fork的子进程)。这样做的目的是确保守护进程能够完全脱离终端和控制台,成为一个独立运行的后台进程;
- 创建子进程,父进程用于监听服务端进程,子进程跳转到(tcp_server.c)称为服务端进程;
- 日志记录(syslog);
- 资源释放与处理;
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <syslog.h>
#include <string.h>
#include <fcntl.h>
#include <signal.h>
#include <string.h>
void signal_handler(int sig); // 用于处理系统信号的函数
void my_daemon(); // 守护进程转换函数
pid_t pid;
int is_shutdown = 0; // 守护进程关闭标志位
int main(int argc, char const *argv[])
{
// 将进程转换为守护进程
my_daemon();
while (1)
{
pid = fork();
if (pid > 0)
{
syslog(LOG_INFO, "守护进程正在监听服务端进程......");
// 等待任意一个子进程退出
waitpid(-1, NULL, 0);
// is_shutdown为1表示整个守护进程即将被关闭
if (is_shutdown)
{
syslog(LOG_NOTICE, "子进程已被回收,即将关闭 syslog 连接,守护进程退出");
closelog();
exit(EXIT_SUCCESS);
}
syslog(LOG_ERR, "服务端进程终止,3s 后重启...");
sleep(3);
}
else if (pid == 0)
{
syslog(LOG_INFO, "子进程 fork 成功");
syslog(LOG_INFO, "启动服务端进程");
char *path = "/home/cool/linux_yyckf/tcp_server/tcp_server";
char *argv[] = {"tcp_server", NULL};
execve(path, argv, NULL);
syslog(LOG_ERR, "服务端进程启动失败");
exit(EXIT_FAILURE);
}
else
{
syslog(LOG_ERR, "子进程 fork 失败");
}
}
return 0;
}
void signal_handler(int sig)
{
switch (sig)
{
case SIGHUP:
syslog(LOG_WARNING, "收到SIGHUP信号!");
break;
case SIGTERM:
syslog(LOG_NOTICE, "接收到终止信号,准备退出守护进程\n向子进程发送SIGTREM信号......");
is_shutdown = 1;
kill(pid, SIGTERM);
break;
default:
syslog(LOG_INFO, "没有收到信号");
break;
}
}
void my_daemon()
{
pid_t pid;
// 第一次创建子进程
pid = fork();
if (pid < 0)
{
exit(EXIT_FAILURE);
}
else if (pid > 0)
{
// 主进程退出
exit(EXIT_SUCCESS);
}
// 如果调用进程不是进程组的领导者,则创建一个新的会话;调用成功则返回调用进程的新会话ID,否则返回-1
if (setsid() < 0)
{
exit(EXIT_FAILURE);
}
// SIGHUP:用于通知守护进程重启或重新加载配置文件
signal(SIGHUP, signal_handler);
// SIGTERM:是一个请求进程终止的信号
signal(SIGTERM, signal_handler);
// 第二次创建子进程
pid = fork();
if (pid < 0)
{
exit(EXIT_FAILURE);
}
else if (pid > 0)
{
// 主进程(第一个子进程)退出
exit(EXIT_SUCCESS);
}
// 确保守护进程创建的文件和目录具有最开放的权限设置
umask(0);
// 将工作目录切换为根目录,便于管理和防止被卸载
chdir("/");
// 关闭所有打开的文件描述符
for (int x = 0; x <= sysconf(_SC_OPEN_MAX); x++)
{
close(x);
}
// LOG_PID 会在日志条目中包含进程 ID;LOG_DAEMON 表示这是一个守护进程的日志消息
openlog("这是守护进程: ", LOG_PID, LOG_DAEMON);
}
Makefile
Makefile
# 指定编译器
CC = gcc
# 指定目标文件
TARGETS = tcp_server tcp_client daemon_test
# 编译所有目标文件
all: $(TARGETS)
tcp_server: tcp_server.c
$(CC) -o $@ $^ -lpthread
tcp_client: tcp_client.c
$(CC) -o $@ $^ -lpthread
daemon_test: daemon_test.c
$(CC) -o $@ $^ -lpthread
# 清理生成的文件
clean:
rm -f $(TARGETS)
在Makefile所在目录终端下执行:make -f Makefile all
即可编译所有目标文件。-f
选项用于指定 make
命令要使用的 Makefile
文件,这里统一都将文件命名为Makefile
。
功能介绍
- 运行daemon_test的可执行文件,即可开启服务器的守护进程;
- 执行tcp_client可执行文件,即可连接到服务进程;在tcp_client客户端所在终端输入想要传输的数据,服务端接收到后会立即返回一条信息,表示服务端收到了客户端的数据。通过
tail -f /var/log/syslog
指令可以查看服务端记录的系统日志,在这里就可以查到刚刚客户端发送给服务端的数据; - 如果运行
kill <服务端PID>
,守护进程会在3s后重启服务端;通过ps -elf
可以查看到服务端PID; - 只有通过
kill <守护进程PID>
指令可以杀死守护进程;通过ps -elf
可以查看到守护进程的PID。