

💪 今日博客励志语录:
人不是突然长大的,而是在某个瞬间,发现自己已经穿过了暴风雨。你回头看去,来时路已是风景。
★★★ 本文前置知识:
网络编程(UDPsocket+TCPsocket)
引入
在上一篇博客中,我详细讲解了网络基本原理与套接字编程。具备了这些知识后,我们便可以编写服务端与客户端程序来实现网络通信。客户端会主动向服务端发起请求,服务端接收到请求后进行处理,并将结果返回给客户端。在传输数据时,客户端与服务端之间主要有两种方式:TCP 协议和UDP 协议。
这两种协议适用于不同的场景。UDP 以数据包为单位进行传输,是一种不可靠传输。这里的“不可靠”意味着:当数据包到达目标主机并递交到传输层后,如果检测到数据包出现差错(例如校验和错误),传输层会直接丢弃该包,而不会通知应用层或发送方。此外,UDP 不需要建立点对点连接,支持一对多通信,因而能够实现广播,即同一数据包可同时发送给多个客户端。
而 TCP 则是一种可靠传输协议,以字节流为单位进行数据传输。其可靠性主要通过超时重传机制和确认应答来保证。具体来说,发送方发出 TCP 报文段后会启动定时器;接收方收到报文段后,会回复一个 ACK 确认报文,告知发送方已成功接收某一序列号的数据。如果发送方在超时前未收到对应的 ACK,便会重传该报文段。此外,TCP 是面向连接的协议,仅支持点对点通信,即一个 TCP 连接对应唯一的服务端套接字与客户端套接字,这一点与 UDP 有显著区别。
以上是对上篇博客部分核心内容的简要回顾,如感到陌生建议返回查阅。我们知道,服务端进程的核心任务是持续接收并处理各个客户端发来的请求,并将处理结果返回对应客户端。由于客户端请求是持续且不定时到来的,这就要求服务端必须 24 小时不间断运行,不能随意退出。
但服务端本身仍是一个进程。假设我们不小心在终端按下 Ctrl+C,向服务端进程发送了 SIGINT(2 号信号),进程便会执行该信号的默认动作——终止自己。一旦服务端退出,所有客户端都会受到影响,无法正常通信。因此,我们必须保证服务端能够稳定运行,不因这类信号或意外操作而中断。
为此,本文将引入第一个核心主题:将服务端进程守护进程化,从而让服务端进程能够长期稳定的运行
守护进程
原理
在正式开始实现守护进程之前,我们首先需要掌握实现守护进程所必需的相关知识。具备了这些基础知识后,我们才能着手进行守护进程的实现。
如果读者和我一样使用云服务器来操作 Linux,不难发现一个现象:Linux 是一个多用户系统。这意味着在同一台 Linux 系统上可以创建多个用户,每个用户都会在根目录下的/home 目录中拥有一个以自己用户名命名的子目录。该目录默认对所属用户具有读、写和执行权限,而对所属组和其他用户一般只具有读和执行权限,没有写权限。目录的创建者可以修改该目录的所属组及其他用户的权限。
/ <-- 根目录
├── home/ <-- 所有普通用户的家目录
│ ├── alice/ <-- 用户 alice 的家目录(权限:alice 完全控制)
│ ├── bob/ <-- 用户 bob 的家目录(权限:bob 完全控制)
│ ├── carol/ <-- 用户 carol 的家目录
│ └── david/
├── root/ <-- 超级用户 root 的家目录
├── etc/
├── var/
└── ...其他系统目录
由于 Linux 是多用户系统,假设不同用户通过Xshell 等工具连接同一台远程服务器,我们会观察到一个直观的现象:系统为每个登录用户都创建了独立的会话界面。用户可以在各自的会话中输入命令或启动进程,不同会话之间的命令执行互不干扰,结果也会输出到对应的对话中。
不仅是不同用户,即便是同一用户多次连接远程服务器,每次登录也会创建一个独立的会话界面。
由此引入一个重要概念——会话。无论是同一用户还是不同用户登录 Linux 时所看到的终端界面,实质上是系统为该登录实例创建的对应会话。
那么,什么是会话?我们可以这样定义:会话由一个或多个进程组以及一个终端(包括终端输入和终端输出)共同构成。每个会话拥有独立的终端输入和输出,这正是不同会话具备独立交互界面的原因。
由于每个会话拥有独立的终端输入输出,不同会话执行相同或不同的 Linux 命令时,结果只会输出到本会话的终端,不会混淆到其他终端。键盘输入的数据也仅由当前会话内的进程接收。这里的“进程组”可暂时理解为一个或多个进程的集合,其具体细节将在后文展开说明。
综上所述,会话本质上是由终端和若干进程组成的执行环境。在使用 Linux 系统时,大家可能遇到过这种情况:如果编写一个包含死循环的程序,不断向屏幕输出字符串,那么启动该程序后,无论输入什么指令,bash 进程都无法执行。
需要明确的是,虽然该程序逻辑上是死循环,理论上会持续运行,但每个进程在运行时都拥有时间片,不可能始终占据 CPU。一旦时间片用完,操作系统会进行进程切换。bash(命令行解释器)本身也是一个进程,因此最终一定会被内核调度并且获得 CPU 执行时间。
bash 的核心逻辑是获取用户键盘输入的字符串(即命令),然后判断是否为内置命令。若是,则由 bash 自身执行;若不是,则通过 fork 创建子进程,再调用进程替换系统调用,将子进程的上下文替换为目标程序的上下文。
这里可能产生疑问:既然该循环程序有时间片限制,最终应会切换到 bash 进程,而 bash 会尝试读取用户键盘输入,那么理论上用户输入的指令应能被 bash 获取并执行。但实际上我们发现,此时输入任何指令,bash 均无法响应,这说明其未能获取到键盘输入。
这就引入了前台进程与后台进程的概念。在同一个会话中,进程具有身份区分,即前台进程与后台进程。值得注意的是,每个会话不仅拥有独立的终端输入输出,还拥有自己专属的 bash 进程。操作系统在创建会话时,除了初始化终端,还会为该会话启动一个 bash 进程,专门处理该会话中用户输入的命令。这正是每个会话能够独立执行用户命令的原因。
在同一时刻,一个会话中只能有一个进程组作为前台进程,但可以同时存在多个后台进程组。会话创建时,bash 进程默认作为前台进程。当我们输入命令时,命令的执行通常涉及创建子进程,此时 bash 会将其前台进程的身份转移给该子进程,使其成为当前会话的前台进程。
了解了前台进程与后台进程的基本概念后,接下来我将补充前文埋下的伏笔——进程组。顾名思义,进程组是一个或多个进程的集合。这固然是进程组最直接的定义,但其中涉及的细节远不止如此。在引入会话的概念后,我们有必要进一步梳理和完善进程之间的关系。
进程组由一个或多个进程组成,这一点是正确的。进程组的出现标识了一种所属关系。在学习进程组之前,我们已经接触过线程。线程实质上是进程的一个分支或执行流,即一个用户态函数的上下文。然而,Linux 操作系统并未严格区分线程与进程,两者均通过 task_struct 结构体来描述。因此,线程也常被称为轻量级进程。
从线程的角度理解,进程本身可视为线程的集合,因而也可称作线程组。在早期学习中,我们接触的多是单线程进程,即仅包含一个主线程。操作系统调度的基本单位是线程,而资源分配的单位则是进程。内核会为每个进程(线程组)创建独立的进程地址空间、文件描述符表及页表,该进程中的所有线程共享这些资源。
为了区分不同的线程和线程组,每个线程对应的 task_struct 结构体中会维护一个 pid 字段,用于唯一标识该线程。此外, task_struct还包含一个 tgid (线程组 ID)字段,用于标识不同的线程组。线程组 ID 与主线程的 pid 相等。
之前我们学习的 getpid() 系统调用所返回的标识符,实际上是 tgid ,而非单个线程的 pid 。现在引入进程组的概念,基于对线程组的理解,我们可以推断进程组是进程的集合。为了区分不同进程组,内核同样会在 task_struct 中维护一个字段,即 pgid (进程组 ID)。一个进程组的 pgid 等于该进程组组长的 tgid ,而进程组组长的 tgid也等于组长主线程的 pid 。
因此,我们可以这样理解:一个线程属于某个线程组(即进程),一个进程属于某个进程组,而一个进程组又属于某个会话。如前所述,Linux 是一个多用户操作系统,每个用户登录时,内核会为其创建一个会话。会话由进程组、终端输入和终端输出组成,内核会为该会话绑定相应的终端,并启动一个专属的 Shell(命令行解释器)。
既然内核中存在多个会话,就必须对其进行区分和管理。熟悉 Linux 设计思想的读者可能会联想到“先描述,再组织”的模式,推测内核会为每个会话定义一个 struct session 结构体,并记录诸如前台进程组 pgid 等属性。但实际上,内核并未为会话单独设计 seeion 结构体,而是通过 task_struct 中的 sid (会话 ID)字段来标识会话归属。所有 sid 相同的线程属于同一个会话。
用户登录
│
↓
创建会话
│
↓
创建控制终端
│
↓
启动 Shell 进程
│
│
├── 进程A (前台进程组) ──┬── 线程1 (主线程)
│ ├── 线程2
│ └── 线程3
│
├── 进程B (后台进程组1) ──┬── 线程1
│ └── 线程2
│
└── 进程C (后台进程组2) ────── 线程1
关系表:
进程/线程 PID TGID PGID SID
--------- ---- ----- ----- -----
Shell 1000 1000 1000 1000
进程A-线程1 1001 1001 1001 1000
进程A-线程2 1002 1001 1001 1000
进程A-线程3 1003 1001 1001 1000
进程B-线程1 1004 1004 1004 1000
进程B-线程2 1005 1004 1004 1000
进程C 1006 1006 1006 1000
理解了上述概念后,读者可能会认为,既然一个线程涉及多个标识符(如 pid、tgid、pgid、sid),并且每个线程对应一个 task_struct 结构体,那么在这个结构体内部,理应通过四个整型字段来分别保存这些标识符的值。
// Linux 2.4 及更早版本的设计
struct task_struct {
// 进程标识符 - 简单整型
pid_t pid; // 进程ID(实际上是线程ID)
pid_t tgid; // 线程组ID(进程ID)
pid_t pgid; // 进程组ID
pid_t sid; // 会话ID
// 进程关系
struct task_struct *parent; // 父进程
struct list_head children; // 子进程链表
struct list_head sibling; // 兄弟进程链表
// 进程状态、调度信息、内存管理等...
// ...
};
然而,如果读者查看过现代 Linux 内核中 task_struct 的定义,会发现线程 ID 与进程 ID 并非简单的整型,而是一个结构体。这背后的原因是什么呢?
要解释清楚这一点,首先需要简要介绍“ 容器”这一概念。有些读者可能听过这个术语,但并未系统性地接触或学习过,这里我们可以先建立一个基本理解。
容器,可以理解为一个独立的运行时环境。操作系统本身也可被视为一个进程,它拥有自己的可执行代码、内存空间,并需要 CPU 来执行。例如,若要在 Windows 主机上运行 Linux 系统,一种传统方法是使用虚拟机。虚拟机本质上是在当前主机上运行的一个应用程序,它模拟出完整的另一套操作系统环境,拥有独立的文件系统、内存空间等,因此开销较大。
相比之下,容器是一种更轻量化的解决方案。容器同样提供独立的运行环境,包括独立的文件系统、网络空间等,但所有容器共享同一个主机内核与物理硬件。因此,在不同容器中运行的进程彼此隔离,各自感知不到对方的存在。
每个容器拥有自己的命名空间,常见类型如下表所示:
命名空间类型 隔离的内容 举例说明
PID 命名空间 进程 ID 视图 进程在容器内 PID=1,在主机上 PID=1000
Mount 命名空间 文件系统挂载点 容器有自己的 /proc、/sys 等
UTS 命名空间 主机名和域名 容器可以有自己的主机名 (hostname)
IPC 命名空间 进程间通信 容器内的共享内存、消息队列独立
Network 命名空间 网络设备、端口 容器有自己的网卡、IP 地址、防火墙规则
User 命名空间 用户和组 ID 容器内 root 用户,主机上是普通用户
我们可以通过一个简单的类比帮助理解容器:将进程想象为同一房间中的不同人,每个人佩戴了一副 VR 眼镜。虽然物理上他们共享同一个房间的资源,但在 VR 眼镜呈现的虚拟环境中,每个人都认为自己独占一个全新的房间,可以使用其中的所有资源,且无法看到其他人。这里的 VR 眼镜就类似于容器,它为进程提供了独立的虚拟运行环境,包括完整的文件系统、网络空间等。在不同容器中运行的进程彼此隔离,如同处于不同的“世界”中。
尽管容器提供了独立、完整的虚拟环境(例如每个容器拥有完整的文件系统视图),但本质上它们仍然共享底层内核与硬件资源,只是这些资源在不同容器中被映射到不同的虚拟视图中。例如,容器的完整文件系统实际上是主机文件系统的一个子目录,但对容器内的进程而言,它只能看到这个子目录所构成的视图,仿佛这就是整个根文件系统。这就像是一个精心构建的“楚门的世界”。
每个容器拥有独立的 PID 命名空间,这意味着在不同命名空间内,进程可以拥有相同的 PID 值。例如,进程 A 在容器 A 中的 PID 可以是 1000,在容器 B 中同样可以是 1000。如果按照之前简单的整型设计,PID 和 TGID 必须是全局唯一的,但引入容器与 PID 命名空间后,这些标识符仅需在各自的命名空间内保持唯一,并与命名空间绑定。这就是为什么现代 task_struct 改用 struct pid 来管理这些标识符。
在现代设计中,每个线程的 task_struct 包含一个 struct pid_link 类型的数组,长度为 4,分别对应四种 ID 类型:TID(线程ID)、PID(线程组ID)、PGID(进程组ID)和 SID(会话ID)。 struct pid_link 包含两个字段: struct hlist_node node和 struct pid* pid 。
// 进程描述符
struct task_struct {
// ...
// 关键:四个链接节点,对应四种ID类型
struct pid_link pids[PIDTYPE_MAX]; // 4个元素的数组
// 为了方便,也有直接指针
struct pid *thread_pid; // 指向线程的PID
// ...
};
enum pid_type {
PIDTYPE_PID, // 线程ID
PIDTYPE_TGID, // 线程组ID
PIDTYPE_PGID, // 进程组ID
PIDTYPE_SID, // 会话ID
PIDTYPE_MAX
};
struct pid_link {
struct hlist_node node; // 链表节点
struct pid *pid; // 指向struct pid的指针
};
每个 pid_link 关联一个 struct pid 结构体。 struct pid 在内核中是全局唯一的(不依赖命名空间)。同一线程组内的所有线程共享相同的 TGID,因此它们对应的 TGID 类型的 pid_link 都指向同一个
struct pid 实例。同理,同一进程组内所有进程的 PGID 也指向同一个 struct pid ,会话(SID)也是如此。
struct pid 内部维护了四个链表头(数组),分别对应四种 ID 类型。链表的节点即为 struct hlist_node 。属于同一线程组、进程组或会话的线程/进程,其 task_struct 中的对应 pid_link.node 会被链接到同一个链表中,从而实现通过 struct pid 来管理所有关联的线程或进程。
struct hlist_node {
struct hlist_node *next, *pprev;
};
而这个 struct hlist_node 正是 task_struct 中 pid_link 的成员,从而间接将线程与对应的 struct pid 关联起来。
PID、TGID、PGID 等标识符总是与特定的命名空间绑定。 struct pid 的关键定义如下:
struct pid {
atomic_t count; // 1. 引用计数
unsigned int level; // 2. 命名空间层级
struct hlist_head tasks[PIDTYPE_MAX]; // 3. 4个链表头
struct rcu_head rcu; // 4. RCU保护机制
struct upid numbers[1]; // 5. 柔性数组,存储命名空间映射
};
其中的 level 表示该 pid 所处的命名空间层级。命名空间可以嵌套,形成树状结构。例如,在初始命名空间(level 0)下创建的容器命名空间层级为 1,在容器内再创建的命名空间层级则为 2,依此类推。
初始命名空间 (level 0)
├── 容器1命名空间 (level 1)
│ ├── 进程A: 在level 0中PID=1000, 在level 1中PID=1
│ └── 进程B: 在level 0中PID=1001, 在level 1中PID=2
└── 容器2命名空间 (level 1)
└── 进程C: 在level 0中PID=1002, 在level 1中PID=1
struct pid 末尾的柔性数组 struct upid numbers[1] 存储了该 pid 在不同层级( level )命名空间中的映射信息。每个 struct pid 可视为一个二元组,记录了在某个特定命名空间下,该 pid 所对应的具体整型 ID 值。一个 struct pid 实例会为它所在的每一层命名空间维护一个这样的映射条目。
struct upid {
int nr; // 在这个命名空间中的数字ID
struct pid_namespace *ns; // 指向命名空间
struct hlist_node pid_chain; // 在命名空间哈希表中的节点
};
struct task_struct
│
├── pids[] (4个pid_link)
│ └── pid (指向struct pid)
↓
struct pid
├── count
├── level
├── tasks[] (4个链表)
├── rcu
└── numbers[] (柔性数组,存储struct upid)
│
└── struct upid
├── nr ← 真正的数值
├── ns ← 命名空间
└── pid_chain
根据上文所述,我们已了解到会话的基本构成。会话由一个终端和多个进程组共同组成,其设计初衷是为了将不同的进程组与不同的终端相绑定,从而使各个进程能够从对应终端接收输入,并将输出结果发送至相应终端。每个会话都拥有一个独立的终端,操作系统需要对这些分属不同会话的终端进行管理,而管理方式遵循“先描述,再组织”的思想,即为每个终端定义一个 tty_struct 结构体。
// Linux内核中的实际结构(简化版)
struct tty_struct {
int magic; // 魔术字
struct kref kref; // 引用计数
// 1. 终端设备
struct tty_driver *driver; // 终端驱动
const struct tty_operations *ops; // 操作函数集
// 2. 输入系统(虚拟键盘)
struct tty_buffer *read_buf; // 读取缓冲区
wait_queue_head_t read_wait; // 读取等待队列
int read_cnt; // 读取计数
// 3. 输出系统(虚拟显示器)
struct tty_buffer *write_buf; // 写入缓冲区
wait_queue_head_t write_wait; // 写入等待队列
int write_cnt; // 写入计数
// 4. 线路规程(特殊字符处理)
struct tty_ldisc *ldisc; // 线路规程
// 5. 会话和进程组管理
struct pid *session; // 会话ID
struct pid *pgrp; // 前台进程组ID
// 6. 终端设置
struct ktermios termios; // 终端属性
struct termiox *termiox; // 扩展属性
// 7. 设备信息
unsigned char *write_buf_ptr; // 当前写指针
int write_buf_size; // 写缓冲区大小
// 8. 状态标志
unsigned int flags; // 标志位
int hw_stopped; // 硬件停止
int stopped; // 软件停止
// 9. 伪终端相关
struct tty_struct *link; // 主从终端链接
struct fasync_struct *fasync; // 异步通知
};
在该结构体中,最核心的字段是用于管理输入缓冲区和输出缓冲区的部分。输入缓冲区用于暂存用户的键盘输入,而输出缓冲区则保存进程向显示器文件写入的数据。
我们知道,每个进程在创建时默认会打开三个文件:标准输入(stdin)、标准输出(stdout)和标准错误(stderr)。在 Linux 中,绝大多数用户进程是由 bash 进程派生而来的子进程。这些进程之所以默认具备这三个打开的文件,是因为 bash 进程自身会首先打开它们。在创建子进程时,系统调用 fork 会将父进程的 task_struct 结构体进行复制(包括文件描述符表、进程地址空间等),并继承父进程的 PID 命名空间,随后修改父进程task_struct 中的相关字段(如 PID),从而形成子进程独立的task_struct 。
由于子进程的文件描述符表是从父进程复制而来,因此它自然继承了这三个已打开的文件。标准输入、标准输出和标准错误本质上对应文件描述符表中的三个下标,该表是一个指针数组,每个元素指向一个file 结构体。在 Linux 中,“一切皆文件”是一个核心设计理念,所有底层硬件和上层软件实体都可以通过文件抽象进行映射。对于标准输入和标准输出对应的file 结构体,其内部的private_data 字段关联到终端的描述符,即
tty_struct 。此外,file 结构体还包含一个f_op 成员,这是一个函数指针表,实际指向所关联tty_struct 中定义的操作函数集。
因此,当我们调用诸如printf 这类向显示器输出的 I/O 函数时,其底层最终会调用write 系统调用,并传入标准输出的文件描述符(通常为 1)。write 系统调用会根据该文件描述符定位到文件描述符表中对应的项,获取其指向的file 结构体,接着调用该结构体中文件操作函数集的write 方法,并结合private_data 指针,转而执行tty_struct 中对应的写操作。此时,用户缓冲区中的数据会被拷贝至tty_struct 的输出缓冲区。
// 简化的输出处理流程
1. 进程调用 write(1, buf, n) // 1 是标准输出文件描述符
2. 系统调用进入内核,通过 current->files->fd[1] 找到 file 结构
3. 调用 file->f_op->write(file, buf, n)
4. 实际调用 tty 驱动的写操作:tty_ops->write(tty, buf, n)
5. 数据被放入 tty_struct 的输出缓冲区 write_buf
进程 write(1, "hello", 5) → 文件系统层 → tty 驱动 → tty_struct.write_buf → 显示器驱动 → 屏幕显示
需要注意的是,如果终端的输出缓冲区已满,当前进程会进入阻塞状态。tty_struct 内部维护了一个因输出而阻塞的进程等待队列。一旦输出缓冲区出现空闲空间,系统便会唤醒该队列中的相应进程,使其继续执行写入操作。
ssize_t tty_write(struct tty_struct *tty, const unsigned char *buf, size_t count) {
size_t written = 0;
int ret = 0;
while (count > 0) {
// 计算缓冲区剩余空间
int space = tty_buffer_space_avail(tty->write_buf);
// 如果缓冲区已满,则阻塞进程
if (space == 0) {
// 设置进程状态为可中断的睡眠
set_current_state(TASK_INTERRUPTIBLE);
// 将进程添加到写等待队列
add_wait_queue(&tty->write_wait, &wait);
// 重新检查缓冲区空间,避免竞争条件
if (tty_buffer_space_avail(tty->write_buf) == 0) {
// 让出CPU,进程进入睡眠
schedule();
}
// 进程被唤醒后,从等待队列移除
remove_wait_queue(&tty->write_wait, &wait);
// 检查是否被信号中断
if (signal_pending(current))
return -ERESTARTSYS;
// 继续尝试写入
continue;
}
// 计算本次写入的数据量(取剩余空间和剩余数据量的最小值)
int chunk = min(count, space);
// 将数据从用户空间复制到内核的tty输出缓冲区
ret = tty_buffer_put_data(tty->write_buf, buf, chunk);
if (ret < 0) {
// 写入失败
break;
}
// 更新指针和计数器
buf += chunk;
count -= chunk;
written += chunk;
tty->write_cnt += chunk;
// 唤醒可能正在等待读取的进程(如果有)
// 注意:这里通常是输入缓冲区,但输出缓冲区写入后可能不需要唤醒读进程
// 这里只是示例,实际可能不同
}
// 如果有数据写入,则尝试启动传输(比如通过驱动程序将数据发送到硬件)
if (written > 0) {
tty->ops->flush_chars(tty);
}
return written;
}
当用户敲击键盘时,每个按键对应一个扫描码。敲击键盘会生成相应的扫描码并触发键盘硬件中断。CPU 接收到硬件中断后,会切换到内核态。内核根据中断号跳转到中断向量表,执行对应的中断处理函数。在该函数中,扫描码被转换为相应字符,并写入输入缓冲区。
当进程调用如 scanf 等获取键盘输入的函数时,其核心是调用 read 系统调用。调用时传入标准输入的文件描述符,内核通过文件描述符表找到对应的 file 结构体,再通过 file 结构体的 private_data 字段定位到关联的 tty_struct 结构体,最终执行其内部的 read 驱动函数,从 tty_struct 的输入缓冲区中读取数据。
需要注意的是,只有当前进程是前台进程时,才有权限读取该输入缓冲区。 tty_struct结构体中记录了当前会话的前台进程的进程组 ID。因此在读取之前,内核会先获取当前进程的 task_struct ,得到其所属进程组 ID,并与 tty_struct 中记录的前台进程组 ID 进行比较。若匹配,则正常读取;若不匹配,则进程会收到一个 SIGTTIN(信号编号 21)信号,其默认行为是使进程停止。
这里需要进一步说明“停止”状态。我们通常接触的进程状态多为运行态,或因等待 I/O、条件未就绪而进入的阻塞(睡眠)状态。内核会使用就绪队列和等待队列等数据结构来组织这些处于运行或阻塞状态的进程。
// task_struct 中的状态标志
struct task_struct {
// ...
volatile long state; // 进程状态
// ...
};
// 进程状态定义
#define TASK_RUNNING 0x0000
#define TASK_INTERRUPTIBLE 0x0001
#define TASK_UNINTERRUPTIBLE 0x0002
#define TASK_STOPPED 0x0004
#define TASK_TRACED 0x0008
// ...
但需要注意的是,当进程进入停止状态(TASK_STOPPED)时,内核并不会为其专门设立一个数据结构进行组织。处于停止状态的进程不会被调度器调度,直到它收到 SIGCONT 信号。此时,其状态会被修改,并重新被置入就绪队列,等待调度。
进程运行 (TASK_RUNNING)
↓ ← 收到 SIGTTIN/SIGTSTP/SIGSTOP
进入停止状态 (TASK_STOPPED)
├── 状态标志更新
├── 从调度器视野消失
├── 不响应大部分信号
└── 等待 SIGCONT 信号
↓ ← 收到 SIGCONT
重新进入就绪队列 (TASK_RUNNING)
↓
等待调度器选择执行
键盘中断 → 扫描码转换 → 终端缓冲区 → 前台进程读取 → 进程处理
此外,还需补充一点:用户登录时会创建一个会话,并同时创建一个终端(更准确地说,是伪终端)。伪终端包含一对设备:主设备(master)和从设备(slave)。前文所讨论的 shell 进程及其子进程通过 printf 向终端输出或通过 scanf 从终端输入,实际上都是与从设备进行交互。读者可能会产生疑问:主设备是什么?从设备又是什么?
主设备和从设备是成对出现的,一个主设备唯一对应一个从设备。创建会话时会创建这样一对终端设备:从设备提供给 shell 命令行解释器及其所有子进程进行交互;主设备则与终端控制进程、SSH 服务端等进程交互。
一个典型的例子是通过 SSH 远程连接 Linux 系统。用户在本地 SSH 客户端输入字符,通过网络传输到远程的 SSH 服务端。SSH 服务端接收到字符后,将其写入主设备的输入缓冲区,接着内核会将这些数据转发到从设备的输入缓冲区。然后,bash 进程从从设备的输入缓冲区读取这些字符(即用户输入的命令),执行后将结果写入从设备的输出缓冲区。随后,数据被转发到主设备的输出缓冲区,最终由 SSH 服务端读取并传回 SSH 客户端,显示给用户。
另一种常见情况是终端模拟器(如 GNOME Terminal、xterm)。终端模拟器负责在显示器上显示内容,并捕获用户的键盘输入。输入字符被终端模拟器捕获后,交给主设备,再经内核转发至从设备,由 shell 读取并执行。输出过程与之相反。
进程(如 bash、vim、ls) → 调用 printf/write
↓
标准输出文件描述符(fd=1)
↓
file 结构体(stdout)
↓
file->private_data → 指向从设备的 tty_struct
↓
调用 tty->ops->write
↓
写入从设备 tty_struct 的输出缓冲区
↓
内核自动转发到主设备 tty_struct 的输入缓冲区
↓
终端模拟器读取主设备的输入缓冲区
↓
渲染到显示器
采用一对设备而非单一设备进行交互,最重要的原因之一是避免数据不一致性问题。如果多个进程(如 SSH 服务端和 shell)并发读写同一个 tty_struct 中的缓冲区,且这些操作不是原子的,就可能导致数据混乱。通过使用一对伪终端,双方各自操作不同的内存空间,从而避免了竞态条件。如果只使用单一终端,理论上可以通过互斥机制保证数据安全,但会降低并发性能。因此,一对设备的设计提高了并发度与整体效率——从设备与 shell 的交互不会影响主设备与另一端进程(如 SSH 服务端或终端模拟器)的交互以及完成了职责的分离。
┌─────────────────────┐ ┌─────────────────────┐
│ 主设备 tty_struct │ │ 从设备 tty_struct │
│ ├── 输入缓冲区 │←→ ├── 输入缓冲区 │
│ ├── 输出缓冲区 │ ←→├── 输出缓冲区 │
│ └── │ │ │
└───┬───────────────┘ └────┬───────────────┘
│ │
终端模拟器 Shell/应用程序
那么这里我们可以输入tty指令,来查看当前会话所绑定的从设备:

根据前文对进程组及其绑定终端设备的介绍,至此我们已经掌握了进程组相关的大部分细节。不过,关于进程组仍有一个重要细节尚未说明:我们已经了解了进程组的定义、进程组ID(PGID)和会话ID(SID)这两个关键属性,那么读者很自然地会问——进程组是如何创建或获取的?进程组组长又是什么?
实际上,我们在Linux的早期学习中已经接触过进程组,只是当时并未意识到。当父进程调用 fork 系统调用创建子进程时,父进程与新创建的子进程便属于同一个进程组。换言之,具有直接父子关系的进程默认位于同一进程组中。
下面我们通过一段简单代码验证这一点。代码逻辑很简单:调用 fork ,根据其返回值分流为两个执行流,父子进程分别进入 while 死循环,以便观察进程状态。之后使用如下命令查看进程信息:
ps -eo pid,ppid,tgid,pgid,sid | grep test
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t id = fork();
if (id == 0) {
// 子进程
while(1) {}
} else {
// 父进程
while(1) {}
}
return 0;
}
其中, ps命令用于查看进程属性与状态,选项 -e 表示显示系统中所有进程, -o 则用于按指定格式输出进程属性。

从输出可见,父子进程的 PID 与 TGID 不同,但 PGID 和 SID 相同。这一结果可引申出多个结论。首先,为什么父子进程的 PID 和 TGID 不同?
我们知道, fork系统调用的底层行为是子进程复制父进程的 task_struct 结构体,从而获得独立的文件描述符表、进程地址空间等资源。而属于同一线程组的线程会高度共享进程资源,包括文件描述符表、进程地址空间及页表等。由于子进程已拥有自己独立的一套资源,与父进程资源相互隔离,因此它们显然不属于同一线程组,而是分属不同的线程组。既然如此,父子进程的 PID 和 TGID 自然不同。
根据结果,父子进程的 PGID 和 SID 相同。SID 相同是预期的,因为两者处于同一会话;PGID 相同则说明子进程的task_struct 中指向struct pid 的pgid 字段会直接指向父进程所对应的struct pid 结构,并递增其引用计数。这里还有一个细节:若父进程的 TGID 与 PGID 相同,则意味着调用 fork 的父进程是该进程组的组长。如果子进程继续调用fork 创建孙进程,可推测孙进程的 PGID 会与父进程、祖父进程相同,而 PID 与 TGID 不同,SID 仍一致。我们可用以下代码验证:
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t id = fork();
if (id == 0) {
// 子进程
pid_t id2 = fork();
if (id2 == 0) {
// 孙进程
while(1) {}
}
while(1) {}
} else {
// 父进程
while(1) {}
}
return 0;
}

由此可见,进程组的第一个来源是:通过fork 创建的子孙进程,即具有直接或间接父子关系的进程,默认属于同一个进程组,且最先调用fork 的那个进程即为该进程组的组长。
我们之前学过kill 命令,它可接受信号编号与进程ID(或线程组ID),并以进程为单位发送信号。在学习进程组后,我们知道kill 系统调用也支持以进程组为单位发送信号,此时需要在进程组ID前添加负号以区别于普通进程ID,例如:
kill -9 -6211
kill 命令本身作为一个进程,其核心是调用kill 系统调用。该系统调用的函数原型为:
int kill(pid_t pid, int sig);
其中pid 参数若为负数,则其绝对值代表进程组ID,此时信号将发送给该进程组内的所有成员。例如:
// 向进程组 1234 的所有进程发送 SIGTERM 信号
kill(-1234, SIGTERM);
在上一篇文章中,我们介绍了 bash 命令行解释器。我们知道,在一个会话中,bash 进程最初是作为前台进程组运行的,可以读取终端输入并获取用户输入的指令。在会话中启动的各种进程,实际上都是 bash 进程的子进程。当 bash 判断用户输入的指令不是内置命令时,它会调用 fork 创建子进程,并通过进程替换函数(如 exec)将子进程的上下文替换为目标程序的上下文。
根据前文的介绍,父进程调用 fork 系统调用后,创建出的子进程默认与父进程属于同一个进程组。由于在会话中启动的进程都是 bash 的子进程或与其具有派生关系,那是否意味着这些进程与 bash 始终属于同一个进程组呢?我们可以通过以下代码进行验证:
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t id = fork();
if (id == 0) {
// 子进程
pid_t id2 = fork();
if (id2 == 0) {
// 孙子进程
while(1) {}
}
while(1) {}
} else {
// 父进程
while(1) {}
}
return 0;
}

我们可以观察到,尽管所启动进程的父进程确实是 bash,但 bash 自身仍属于其原有的进程组,而父进程、子进程及孙子进程则归属另一个进程组。我们知道,bash 实际上是通过调用 fork 创建子进程,再将子进程的上下文替换为目标程序。结合上述现象,只能说明一点:bash 会在创建子进程后与其在进程组层面进行分离。这里就需要引入 setpgid 系统调用。
setpgid头文件:<unistd.h>
声明:int setpgid(pid_t pid, pid_t pgid);
返回值:成功返回 0,失败返回 -1(并设置 errno)
下面对 setpgid 的参数进行说明:
第一个参数 pid 表示要设置的进程 ID。如果为 0,表示当前进程;如果大于 0,则指定为对应 PID 的进程。
第二个参数 pgid 表示目标进程组 ID。如果为 0,则将进程组 ID 设置为与 pid 相同的值;如果大于 0,则明确指定进程组 ID。
setpgid 的一个主要作用是将当前进程设置为一个新进程组的组长,例如:
// 将当前进程设为新进程组的组长(进程组ID = 当前进程ID)
setpgid(0, 0);
另一个作用是将指定进程加入到已有的进程组中:
// 将进程 1234 加入到进程组 5678
setpgid(1234, 5678);
在现阶段的学习中,我们可能暂时用不到该系统调用,此处仅作了解。理解该系统调用后,我们便可以解释上文的现象:bash 在 fork 出子进程后,子进程并不会与 bash 处于同一进程组。这是因为 fork 调用之后,根据返回值分为父进程和子进程两个执行流。在子进程的执行流中、调用 exec 进行进程替换之前,bash 会先调用 setpgid ,将子进程设为新进程组的组长,然后再执行进程替换。这也是为什么新进程组的组长通常就是刚创建出来的子进程。
这样设计的主要目的,是为了将会话中的 bash 与其他进程分离,使它们处于不同的进程组。这是因为在终端中,我们除了输入命令,还可以输入键盘组合键,这些组合键会映射为信号,例如 Ctrl+C 对应 SIGINT , Ctrl+Z 对应 SIGTSTP 。这些信号默认发送给前台进程组,而不是后台进程组。前台进程组在任一时刻只能有一个,而后台进程组可以有多个。由于终端发送信号的对象是进程组,为了避免 bash 及其他后台进程意外接收到终端信号,必须在创建子进程后将其设置为新的进程组,这正是通过调用 setpgid实现的。
此外,如果用户输入的指令末尾没有 & 符号,该进程还应被设为前台进程。因此,除了调用 setpgid ,在子进程中还会调用 tcsetpgrp 系统调用来完成前台进程组的设置:
tcsetpgrp- 头文件:<unistd.h>
- 声明:int tcsetpgrp(int fd, pid_t pgrp);
- 返回值:成功返回 0,失败返回 -1(并设置 errno)
其中,第一个参数 fd 一般为标准输入的文件描述符( STDIN_FLENO ),第二个参数 pgrp 是要设置为前台进程组的进程组 ID。该系统调用通常与 setpgid 配合使用,目前在初步学习阶段也仅作了解即可。
因此,在 fork 创建子进程后,子进程的执行流(即 fork 之后、exec 之前)会先调用 setpgid 设置新进程组,接着调用 tcsetpgrp 将自身所在进程组设为前台进程组。这意味着该进程组成为前台进程组,而 bash 进程组则转为后台进程组,因为前台在同一时刻只能有一个进程组担任。
pid_t child_pid = fork();
if (child_pid == 0) {
// 子进程
// 设置进程组
setpgid(0, 0);
// 2. 如果是前台命令,设置为前台进程组
if (!run_in_background) {
tcsetpgrp(STDIN_FILENO, getpgrp());
}
// 执行命令
execvp(...);
} else {
// 父进程(Shell)
// 设置子进程的进程组,避免竞态条件
setpgid(child_pid, child_pid);
// 如果不是后台命令,则将终端的前台进程组设置为子进程的进程组
if (!is_background) {
tcsetpgrp(STDIN_FILENO, child_pid);
}
// 如果不是后台命令,则等待子进程
if (!is_background) {
waitpid(child_pid, ...);
// 当子进程结束后,Shell重新获得终端控制权
tcsetpgrp(STDIN_FILENO, getpid());
} else {
// 后台命令:不等待,继续提示符
printf("[%d] %d\n", job_number, child_pid);
}
}
此外,根据上文的讨论可知,当我们启动一个进程时,该进程默认会作为前台进程运行。除非在启动命令的末尾添加 & 符号,此时进程会转为后台运行。在命令后添加 & 后,终端会输出如下格式的字符串:

其中,第二个数字为进程 ID(PID),也可以视为线程组 ID。那么第一个数字代表什么呢?这就引入了作业(job)的概念。
在早期 Linux 系统的设计中,进程的核心作用是完成某项任务。这也是内核将进程描述结构体命名为 task_struct 的原因。引入进程组概念后,一个任务不必只由一个进程完成,也可以交由一个进程组协作完成。因此,作业的本质就是进程组,或者说,作业是进程组在 Shell 管理层面的一个称呼。
在 bash 进程的视角中,它创建的子进程(或进程组)就是一个作业。每个作业都具有一个明确的身份:要么是前台作业,要么是后台作业。bash 的一个重要功能就是管理这些作业,包括创建作业、分配前后台状态,并管理其生命周期。
管理的方式仍然是经典的“先描述,再组织”。bash 会定义一个 struct job 结构体,其中包含作业对应的进程组 ID、作业状态、命令行字符串等信息。为了区分不同作业,每个作业会被赋予一个唯一的作业号(job ID)。而上面终端输出中的第一个数字,正是这个作业号。
struct job {
int job_id; // 作业号,如 [1], [2], [3]...
pid_t pgid; // 进程组 ID
char *command; // 命令行字符串
// 作业状态
enum {
JOB_RUNNING_FG, // 前台运行
JOB_RUNNING_BG, // 后台运行
JOB_STOPPED, // 暂停(例如通过 Ctrl-Z)
JOB_DONE, // 正常完成
JOB_TERMINATED // 被信号终止
} state;
// 标记位
int flags;
#define JOB_NOTIFIED 0x01 // 已通知用户作业状态变化
#define JOB_FOREGROUND 0x02 // 标记为前台作业
// 特殊标记
int is_current; // 当前作业 (+)
int is_previous; // 上一个作业 (-)
// 进程列表(用于管道命令,可能包含多个进程)
struct process *first_process;
// 链表指针,用于作业队列管理
struct job *next;
struct job *prev;
};
而bash创建出的所有作业,会以链表的形式来组织,其中对于作业的管理,那么就是对该链表的增删改查。
在引入 作业这一概念后,便可以进一步介绍与作业管理相关的指令。首先, jobs指令用于展示当前会话下创建的所有后台作业。它会列出每个后台作业的作业号、对应的进程 ID 以及作业状态。

在使用 jobs 时,可能会注意到除了作业号与进程 ID 外,某些作业号后附有“ +”或“ -”符号。这些符号具有特定含义:
- 作业号后带“
+”表示该作业是当前作业,即最近一次被操作的后台作业。此处的“操作”包括创建该后台作业或将其切换到前台。 - 作业号后带“
-”表示该作业是上一个当前作业,即在最近一次操作之前带“+”符号的作业;当新的当前作业出现时,原“+”作业会变为“-”作业。
除了 jobs 之外,常用的作业管理指令还包括 fg 和 bg 。
fg(foreground)用于将后台作业切换到前台执行。bg(background)用于将前台作业转移到后台继续运行。可通过指令首字母辅助记忆:
f代表 front(前台),b代表 back(后台)。在使用时,fg与bg后一般需指定作业号作为参数。
以下图运行为例,假设已创建三个后台作业。若将其中一个切换到前台,而该作业对应的进程为不读取终端输入的死循环逻辑,则终端将无法响应其他命令输入。但由于该进程仍可接收终端信号,此时若输入Ctrl+C 组合键(发送SIGINT 信号),
进程会被终止。

值得注意的是,如果在使用 fg 时未指定参数,则默认将当前作业(即带“+”的作业)切换到前台。例如,若当前作业为 3 号作业,则执行 fg 会将其移至前台。
另外,如果创建的前台作业为不读取终端输入的死循环,可通过Ctrl+Z 组合键向进程发送SIGTSTP 信号。该信号的默认行为是将进程状态从“运行”切换为“暂停”。一旦进程暂停,它会被移出就绪队列;若是前台进程,则不再占用终端输入,也不再接收终端信号,此时终端将恢复可操作状态。

实际上,Bash 父进程会监视所创建作业的状态变化。当检测到某个前台进程被暂停(停止)时,Bash 会自动将其转为后台作业,并重新接管前台控制权。这也解释了为何在按下Ctrl+Z 后,执行 jobs 会看到该进程已被移至后台列表中。
当读者阅读到这里时,可能会产生一个疑问:如果前台进程收到与 Ctrl+Z 对应的 SIGTSTP 信号,它会进入暂停状态,并会被切换到后台进程。此时 bash 进程会重新获得终端的控制权,即成为前台进程。值得注意的一点是,进入暂停状态的前台进程之所以会被切换为后台进程,是因为 bash 能够感知到作业状态的变化。那么,bash 进程是如何感知到作业状态发生变化的呢?
首先需要明确,无论是前台作业还是后台作业,它们本质上都是 bash 的子进程。bash 会调用 fork() 系统调用,之后根据返回值分支出父、子两个执行流。对于父进程(即 bash 进程)的执行流,首先会将自身与子进程分离,并为子进程设置一个新的进程组,同时为该进程组定义对应的 struct job 结构体,并将其加入作业表中。接着,bash 会检查这个子进程是否是前台进程。如果是,则会先交出终端控制权(通过调用tcsetpgrp),然后调用 waitpid 进入阻塞等待状态。如果前台进程退出,bash 进程会被唤醒,并获取其退出状态,随后更新该进程组对应的 struct job 结构体的相关字段。
这里需要注意, waitpid的作用是获取子进程的退出状态。如果子进程尚未退出,父进程会一直阻塞。但 waitpid 的等待条件不仅包括子进程退出,还包括子进程收到像 SIGTSTP 这样默认动作为暂停进程的信号。此时,父进程也会被唤醒并恢复执行。为了实现这一点,需要在调用 waitpid 时设置选项宏 WUNTRACED 。
// 情况1:不使用 WUNTRACED
waitpid(pid, &status, 0);
// 父进程在此阻塞,直至子进程终止
// 即使子进程暂停,父进程也不会感知
// 情况2:使用 WUNTRACED
waitpid(pid, &status, WUNTRACED);
// 父进程在此阻塞
// 子进程一旦暂停,父进程立即收到通知
设置 WUNTRACED 选项后,一旦子进程暂停,父进程(即 bash)会立即恢复执行。它首先会检查子进程的当前状态,如果是暂停状态,则会重新获取终端控制权(将自身设置为前台进程组),并更新该子进程对应的 struct job 结构体的相关字段。如果子进程退出,父进程同样会更新其 struct job 结构体的状态,然后继续执行。
那么,进入暂停状态的子进程是如何通知父进程的呢?这是因为在子进程的 task_struct结构中维护着一个等待队列,其本质是一个链表。链表中的每个节点代表一个因等待该子进程而阻塞的进程。值得注意的是,这个链表中的节点不一定只有父进程,还可能包括其他进程,比如 gdb 调试器(它会追踪被调试进程的状态)。每个节点是一个结构体,其中包含多个字段,如指向等待进程的 task_struct 指针、等待选项以及回调函数等。当父进程调用 waitpid 时,该系统调用的一个关键操作就是创建一个等待队列节点,并将其添加到子进程的等待队列中。
struct wait_queue_entry {
unsigned int flags; // 标志位
void *private; // 通常指向等待进程的 task_struct
wait_queue_func_t func; // 唤醒回调函数
struct list_head entry; // 链表节点
int *options; // 等待选项(如 WUNTRACED)
};
// 子进程 task_struct 中的相关字段示例
struct task_struct {
// ...
// 等待队列头
wait_queue_head_t wait_chldexit; // 等待子进程退出的队列
// 子进程状态信息
int exit_state; // 退出状态
int exit_code; // 退出码
int exit_signal; // 导致退出的信号
// 进程状态
volatile long state; // 运行状态
// ...
};
这里的回调函数用于检查等待条件是否满足。例如,当一个进程从运行状态切换到暂停状态时,内核除了修改该进程 task_struct 中的状态字段并将其移出就绪队列外,还会遍历该进程等待队列中的所有节点,依次检查是否有节点的等待条件被满足(即调用其回调函数)。如果某个节点的条件满足(比如父进程在 waitpid 中设置了 WUNTRACED ),内核会定位到对应的父进程,将其唤醒,并将该节点从等待队列中移除。
父进程的 task_struct 中有一个 struct wait_opts 类型的字段,其中记录了等待进程的详细信息,如 PID 和选项等。 waitpid 在创建节点并加入子进程等待队列的同时,也会设置这个结构体,其内容主要用于在回调函数中作为参数来判断条件是否满足。
struct wait_opts {
enum pid_type wo_type; /* 等待的进程类型(PID、PGID 等) */
int wo_flags; /* 选项:WUNTRACED、WNOHANG 等 */
pid_t wo_pid; /* 等待的 PID(或 -PGID) */
struct siginfo __user *wo_info; /* 返回的状态信息 */
int __user *wo_stat; /* 返回的状态 */
// ...
};
static int child_wait_callback(wait_queue_entry_t *wait, unsigned mode, int sync, void *key)
{
struct wait_opts *wo = container_of(wait, struct wait_opts, child_wait);
struct task_struct *p = key; // 子进程的 task_struct
// 检查子进程 p 是否满足 wo 中指定的条件
// 若满足,则收集状态信息,唤醒父进程
// 返回 1 表示已处理,0 表示未处理
}
我们可以通过一个简单示例来理解整个过程:
# 用户在前台运行一个命令
$ sleep 100
^Z
# 用户按下 Ctrl+Z
# 内部发生的事件:
1. 终端驱动程序将 Ctrl+Z 转换为 SIGTSTP 信号
2. 内核将 SIGTSTP 发送给 sleep 进程的进程组
3. sleep 进程收到 SIGTSTP,默认动作是暂停
4. 内核将 sleep 进程状态改为 TASK_STOPPED
5. 内核遍历 sleep 进程的等待队列
6. Bash 的 waitpid 调用满足 WUNTRACED 条件
7. 内核唤醒 Bash 进程
8. Bash 的 waitpid 返回,检测到 WIFSTOPPED
9. Bash 调用 tcsetpgrp 夺回终端控制权
10. Bash 打印: [1]+ Stopped sleep 100
11. Bash 显示新的提示符,等待用户输入
进程,但这次不关闭会话,而是从另一个会话中向该会话的首进程(即 bash 进程)发送 SIGKILL 信号(信号编号 9)。随后通过 ps 命令查看进程状态,可以发现这些后台进程并没有被终止,它们仍然存在,但不再与任何终端关联,并且其父进程 ID(ppid)变为 1。这是因为原来的父进程(bash)意外终止后,这些后台进程被操作系统“领养”,通常由 init 进程(pid 为 1)接管。
根据上述内容,我们还需要补充一点:如前所述,父子进程默认属于同一个进程组。而对于我们输入的管道指令(由 “|” 连接多个基本命令构成),其中每个基本命令对应一个进程,这些进程同属于一个进程组,其进程组组长即为第一个基本命令对应的进程。
cmd1 | cmd2 | cmd3
至此,我们已经掌握了会话和进程组的相关知识。我们知道,bash 进程是会话的首进程。

当用户关闭会话时,会话中所有的前台进程以及所有的后台进程都会被终止。

具体来说,会话关闭意味着会话被销毁。此时,内核会以会话为单位,向该会话中的所有进程组(包括 bash 进程)发送 SIGHUP 信号,该信号的默认行为是终止进程。
接下来我们观察第二种现象。我仍在同一个会话中启动三个后台进程,但这次不关闭会话,而是从另一个会话中向该会话的首进程(即 bash 进程)发送 SIGKILL 信号(信号编号 9)。随后通过 ps 命令查看进程状态,可以发现这些后台进程并没有被终止,它们仍然存在,但不再与任何终端关联,并且其父进程 ID(ppid)变为 1。这是因为原来的父进程(bash)意外终止后,这些后台进程被操作系统“领养”,通常由 init 进程(pid 为 1)接管。

接着,我们关闭所有当前会话并重新登录,再次使用 ps 命令查看测试进程(test)。可以看到,这些进程的 PID、PGID 等属性均未改变,其 SID 也仍然存在。这是因为在同一会话下,所有进程的 task_struct 中指向 SID 的 struct pid 字段都引用同一个 struct pid 结构体。该结构体内部维护一个引用计数,只有当会话中所有进程都终止时,它才会被释放。因此,即使会话首进程(bash)被终止,其他后台进程仍继续引用该 struct pid,意味着会话 ID 依然保留。不过,由于会话首进程已终止,该会话关联的终端会被销毁,而后台进程则继续保留在系统中。

基于以上三种现象,我们可以理解守护进程实现的一个重要原则:服务端进程绝不能受会话生命周期的影响。一旦服务端所在的话关闭,它会收到 SIGHUP 信号并默认被终止,这会导致服务意外中断。
可能有读者会想到,既然不希望服务端进程因会话关闭而终止,能否直接忽略 SIGHUP 信号?这样即使会话关闭,进程也不会因此退出。
但需要注意的是,忽略 SIGHUP 信号虽然能避免进程被终止,但会话关闭时其关联的终端也会被销毁。如果服务端进程的标准输入、标准输出和标准错误仍关联到已被销毁的终端,后续针对这些文件描述符的读写操作将会出错。因此,更妥善的做法是让服务端进程脱离原会话,成为一个独立的守护进程。
实现
在掌握上述原理后,接下来我们可以动手编写代码,实现一个守护进程。根据前文所述,实现守护进程的核心是使进程脱离当前会话,因此这里最关键的步骤是调用 setsid 系统调用。该系统调用的作用是:使当前进程脱离原会话,并将其设置为新进程组的组长。
setsid- 头文件:<unistd.h>
- 函数声明:pid_t setsid(void);
- 返回值:成功时返回会话 ID(即调用进程的进程 ID),失败时返回 -1(并设置
errno)
要注意的是,调用 setsid 有一个前提条件:调用进程不能是进程组的组长。对于一个单进程程序来说,该进程自身就是其进程组的组长(因为进程组中只有它一个进程)。为了避免这一情况,我们可以先调用 fork 创建子进程。此时,父进程仍然是进程组组长,而子进程则成为该进程组的成员。这样子进程就可以在其执行流中安全调用 setsid 。
父进程的作用仅限于通过 fork 创建子进程,之后它便没有其他任务,因此可以直接调用 exit 正常退出。此时,子进程成为孤儿进程,由操作系统接管。如果子进程成功调用 setsid ,它将脱离原会话,并进入一个新会话。需要注意的是, setsid 虽然会创建新会话,但不会自动为该会话分配控制终端,因此子进程不会与任何终端关联。这意味着其标准输入、标准输出和标准错误所关联的终端实际上是无效的。
一种常见的做法是直接关闭这三个标准文件描述符,但还有一种更优雅的方式:利用内核提供的特殊设备文件 /dev/null 。该设备文件就像一个“黑洞”,所有写入其中的数据都会被丢弃。我们可以先打开 /dev/null ,然后将标准输入、标准输出和标准错误重定向到该文件。
最后还有一个细节需要注意:建议将守护进程的工作目录切换到根目录。因为根目录通常不会被普通用户删除,而默认情况下,服务端进程会继承父进程(如 bash)的工作目录,这可能是一个普通用户目录,存在被删除的风险。因此,通常我们会调用 chdir("/") 将守护进程的工作目录切换到根目录。
#include<unistd.h>
#include<string>
#include<cstdlib>
#include<signal.h>
#include<fcntl.h>
#include"log.hpp"
extern log lg;
std::string filename = "/dev/null";
std::string workingdirectory="/";
class Daemon
{
public:
void daemon()
{
signal(SIGCHLD,SIG_IGN);
signal(SIGPIPE,SIG_IGN);
signal(SIGHUP,SIG_IGN);
pid_t id=fork();
if(id<0)
{
lg.logmessage(Fatal,"fork fail");
exit(1);
}else if(id>0)
{
exit(0);
}
int n=setsid();
if(n<0)
{
lg.logmessage(Fatal,"setsid fail");
exit(1);
}
int fd = open(filename.c_str(), O_RDWR);
if (fd < 0)
{
lg.logmessage(Fatal, "open dev/null fail");
exit(1);
}
dup2(fd,STDIN_FILENO);
dup2(fd,STDOUT_FILENO);
dup2(fd,STDERR_FILENO);
if(chdir((workingdirectory).c_str())<0)
{
lg.logmessage(Fatal,"chdir fail");
close(fd);
exit(1);
}
lg.logmessage(info,"demonize successfully");
}
};
根据上文所述,将服务端进程转化为守护进程的核心步骤是使其 脱离当前会话 。这样一来,服务端进程便不会接收来自终端的信号,并且在会话关闭时,也不会收到内核发送的 SIGHUP 信号。实现守护进程化的关键思路,正是如上文所说——通过调用 fork 系统调用来创建子进程。由于子进程并非进程组组长,因此它可以成功调用 setsid 系统调用,从而脱离原会话。
所谓“脱离当前会话”,是指内核会新建一个会话,其中包含一个全新的会话 ID( sid ),并对应一个新的 struct pid 实例。接着,内核会将该进程对应的 task_struct 结构体中指向会话 ID 类型的 struct pid 的指针,更新为指向这个新创建的 struct pid 。在修改指针之前,内核会先定位到原有的 struct pid 实例,并将其引用计数减一。
此外,创建新会话这一操作本质上就是创建一个 struct pid 结构体,而并不会为该会话创建对应的终端。因此,调用 setsid 后,该子进程不再与任何终端绑定,导致标准输入、标准输出和标准错误的文件描述符失效。一种优雅的处理方式是,将这些文件描述符重定向到特殊的设备文件 /dev/null 。同时,通常还会将进程的工作目录切换至根目录,这是因为根目录最为稳定,一般不会被轻易删除。
以上过程可以手动实现,但系统也提供了一个封装好的库函数 daemon ,其内部正是通过调用 setsid 与 fork 系统调用来完成上述步骤:
daemon- 头文件:<unistd.h>
- 函数声明:int daemon(int nochdir, int noclose);
- 返回值:成功时返回 0,失败时返回 -1
其中,第一个参数 nochdir 控制是否切换工作目录至根目录:若为 0,则切换;若非 0,则保持当前工作目录不变。
第二个参数 noclose 控制是否重定向标准输入、输出和错误到 /dev/null :若为 0,则执行重定向;若非 0,则保持原有文件描述符不变。
这里一般还是建议我们自己手动实现一个守护进程化的接口,从而可以自己灵活控制有关细节
自定义协议
在掌握了如何实现守护进程之后,接下来我们将目光聚焦到应用层。之前我们已经学习过TCP协议,知道其最重要的特征之一是面向字节流的传输。所谓面向字节流,是指数据的发送与接收都以字节为基本单位,这一点可以从读取数据的 read() 接口和发送数据的 write() 接口看出。
在学习TCP协议时,我们知道应用层可以一次发送任意长度的字节数据。但由于TCP协议存在最大报文段长度( MSS)的限制,如果应用层要发送的数据长度超过 MSS,数据就会被分段传输。这些分段后的TCP报文段可能会以乱序方式到达接收端主机。接收端收到这些乱序的报文段后,会将其递交给传输层,传输层再根据序列号对这些报文段进行重组。
然而,这里还存在一个问题:接收端调用 read() 接口读取数据时,是以字节为单位从内核缓冲区读取指定长度的数据到用户缓冲区。由于TCP是面向字节流的,接收端无法直接感知数据的边界。因此,在一次读取操作中,接收端可能只读到某个完整报文段的一部分,也可能一次读取过多数据,导致同时读到当前报文段的全部内容以及下一个报文段的开头部分。
对发送方而言,通常期望接收方每次能读取一个完整的应用层报文段,既不多也不少。为此,必须在应用层对要发送的数据进行额外处理——除了实际数据外,还需在数据前面封装一个应用层协议头部。
这个协议头部长度一般是固定的,例如4字节,其中至少包含两个字段:消息长度和消息类型。
在通信之前,收发双方必须预先约定好协议头部的格式和长度。接收方首先读取固定长度的协议头部,解析其中的各个字段,尤其是消息长度字段。通过该字段,接收方就能知道后续数据部分的确切大小,从而准确读取一个完整的应用层报文,明确消息之间的边界。
消息类型字段则用于告诉接收方,在获取完整报文后应如何解析数据部分的内容。该字段指明了报文的具体格式,例如,若消息类型为“登录消息”,则接收方知道数据部分应按“用户名-身份-密码”的格式解析;若为“普通消息”,则格式可能是“用户名-发送时间-消息内容”。注意,发送方只需传递消息类型编号,而无需附带其对应的格式说明,因为双方已事先约定好每种消息类型对应的数据结构。接收方根据消息类型,即可按约定格式解析消息正文。
综上所述,一个完整的应用层数据应由两部分构成:固定长度的应用层头部和实际的消息正文。头部的作用是让接收方能识别消息边界并理解消息格式,从而正确、完整地解析信息。
但随之而来的是另一个问题:应如何封装这样的应用层数据?最直观的想法是定义一个结构体,其成员对应协议头部中的各个字段(如长度、类型),再包含一个数组字段用于存放消息正文,然后将整个结构体转为字节流通过网络发送。接收方收到字节流后,可将其重新解释为相同结构体,并提取各字段的值。
然而,这种方法存在严重缺陷。由于网络通信可能发生在不同平台、不同编译器的系统之间,而结构体的内存布局(包括各字段的偏移、对齐填充字节、整体大小)受编译器和平台影响,可能不一致。这会导致接收方无法正确解析出协议字段,因此直接传递结构体对象是不可行的。要解决这个问题,就需要引入序列化与反序列化机制。
序列化与反序列化
根据上文所述,在通信过程中不能直接将结构体对象转换为 字节流 传输,是因为通信双方可能处于不同的平台或使用不同的编译器,导致结构体的大小和各个字段的偏移量存在差异。因此,需要引入 序列化与反序列化 机制。
所谓的序列化与反序列化,是指网络通信中传输的都是字节流,即将发送的数据按照字节为单位进行传输。为此,我们必须对待发送的数据进行处理。通常,发送的数据可分为协议头部分和消息正文部分。发送方和接收方会通过结构体来描述完整的应用层数据,其中结构体的各字段对应协议部分的各项信息以及消息正文的内容。但在实际发送时,我们会将该结构体中所有字段的数据提取出来,组合成一个完整的字符串,并将这个字符串作为整体发送。
由于网络传输的是字节流,而字符串的基本单位就是字节,那么回顾之前提到的问题:如果将结构体直接整体转换为字节流发送,接收端会将整个字节流视为结构体的完整数据,并按照其内部字段的偏移量来解析各个字段。但由于不同平台和编译器的差异,字段偏移量可能不同,导致无法正确定位各字段的位置。
如果我们将结构体所有字段提取并转为字符串,再组合成一个完整的字符串,则每个字段在该字符串中的位置是固定的,在网络传输过程中不会发生变化。例如,若协议中消息正文字段长度转换为字符串后占两个字节,并且它便位于整个字符串的开头,即前两个字节表示消息正文长度。这个字符串对应的字节流经网络传输到达目标主机的应用层后,应用层会将其作为字符串解析,其前两个字节仍然表示消息正文的长度,不会发生任何错位。
对网络通信有一定了解的读者可能会产生疑问:网络传输中存在 字节序 (大小端)的问题,不同主机可能采用不同的字节序,那么在将数据转换为完整字符串之前,是否需要先将数据转换为网络字节序再发送呢?
这一疑问源于对字节序概念的理解还不够透彻。需注意,字节序是针对 多字节数据类型 (如 int 或 long)而言的,这些类型的数据由多个字节构成,解析时需要将多个字节作为整体处理。在这些多字节数据内部,字节的排列顺序与数据内容相关。以大端序为例,一个 4 字节的 int 类型数据,权值高的字节存放在低地址,权值低的字节存放在高地址;小端序则相反
而对于 char 类型这种单字节数据,并不存在字节序问题。字符串解析以字节为单位进行,不涉及多个字节重新排列。例如,发送方发送的字节流“12345”,接收方收到的依然是“12345”,字节的顺序不会改变。因此,在这种基于字符串的序列化方式中,无需进行网络字节序的转换。
上述方式属于序列化中的 文本方式 。其优点在于无需担心字节序转换,便于调试和阅读;缺点是体积较大。例如,发送整数 123455,若以 int 类型直接发送仅需 4 个字节,而使用文本方式则需转换为字符串"123455",占用 6 个字节,会造成较大的数据膨胀,且接收方需要进行字符串解析。
第二种序列化方式是 二级制序列化 。与文本方式不同,它无需将数据转换为字符串,而是直接发送数据的原始二进制序列。例如,int 类型数据就直接以其原始 4 字节发送到网络。二进制序列化需要注意字节序问题,但其解析效率较高,且数据体积相对较小。
所以序列化的本质就是内存中结构化的数据按照预先约定好的规则转换为一个连续的字节序列的过程,以c++为例,这个结构化的数据是指,我们将消息正文部分的内容分别存放到一个结构体或者对象的各个字段当中,那么便于管理与组织
那么知道了序列化之后,那么反序列化的概念就很简单了,那么序列化就是将发送的应用层数据转换为了特定格式的字节流,而所谓的反序列化,就是对方收到这个字节流之后,按照约定的规则解析这个字节流还原出发送方想要发送的结构化的原始数据,这就是所谓的反序列化,其实就是序列化的逆过程
网络计算器
整体实现框架以及核心细节
在掌握自定义协议以及序列化与反序列化相关知识的基础上,我们可以着手实现一个简易的网络计算器程序。该程序由客户端与服务端两部分组成:客户端负责向服务端发送计算请求,服务端接收请求并进行处理,最终将计算结果返回给客户端。
客户端发送的请求内容较为简单,主要包括需要计算的两个操作数及一个运算符。服务端接收到请求后执行相应的算术运算,并将运算结果与执行状态(成功或错误)返回给客户端。
服务端在此实现中提供的是短连接服务,即基于 TCP 协议与客户端建立连接后,仅处理一次请求。请求处理完成后,服务端会主动断开连接,结束本次通信。
以上是网络计算器的整体框架。接下来,我们将深入讨论程序实现中的核心细节。首先,客户端与服务端之间传输的应用层数据,除了实际的消息正文之外,还应包含协议头部。在本程序中,协议头部设计较为简单,仅包含一个表示消息正文长度的字段,不包含其他信息。
客户端需要发送的消息正文封装在一个结构化的 Request 结构体中,其包含两个 int 类型的操作数字段与一个 char 类型的运算符字段。客户端负责初始化该结构体的各个字段。
class Request
{
public:
Request(int _data1, int _data2, char _op)
: data1(_data1)
, data2(_data2)
, op(_op)
{}
// ...
public:
int data1;
int data2;
char op;
};
需要注意的是,由于内存对齐、字节序等因素,不能直接将结构体转换为字节流进行网络传输。在发送之前,必须先对结构体进行序列化,将结构化数据转换为连续的字节序列。序列化完成后,需在字节流前添加协议头(即长度字段),再通过网络发送。
客户端发送流程如下:
1. 构造 Request 结构体
2. 序列化 Request → 字节流
3. 添加协议头(包含字节流长度)
4. 发送给服务器
服务端接收到字节流后,首先从协议头部解析出消息正文的长度,进而分离出消息正文部分。之后,需按照预先约定的序列化规则对消息正文进行反序列化,还原出原始的 Request 结构体,以进行后续计算处理。
计算完成后,服务端将运算结果与状态码封装到 Response 结构体中,其包含表示运算结果的 Result 字段与表示执行状态的 code 字段。其中,状态码为 0 表示计算正确,非 0 表示发生特定错误。
class Response
{
public:
Response(int _result, int _code)
: result(_result)
, code(_code)
{}
Response()
: result(0)
, code(0)
{}
// ...
public:
int result;
int code;
};
同样, Response 结构体也不能直接发送。在返回给客户端之前,需先对其进行序列化,并为序列化得到的字节流添加协议头部,最后通过网络发送。
服务端的处理流程可归纳如下:
1. 接收数据
2. 解析协议头,获取正文长度
3. 读取对应长度的消息正文
4. 反序列化正文 → Request 结构体
5. 执行业务计算,得到 Response
6. 序列化 Response → 字节流
7. 添加协议头
8. 发送回客户端
客户端收到响应后,需按照相反顺序进行解析:先根据协议头获取正文长度,分离出消息正文,再对正文进行反序列化,最终得到服务端返回的 Response 结构体,从而获得运算结果与状态信息。
以下为完整的交互流程示意图:
客户端:
[Request结构体]
↓ 序列化(统一字节序)
[字节序列]
↓ 添加长度协议头
[协议头 + 消息正文]
↓ 经 TCP 发送
→
服务端:
[接收完整数据包]
↓ 分离协议头
[消息正文]
↓ 反序列化
[Request结构体]
↓ 业务处理
[Response结构体]
↓ 序列化 + 添加协议头
[协议头 + 响应正文]
↓ 发送响应
→
客户端:
[接收响应数据]
↓ 解析协议头,提取正文
[响应正文]
↓ 反序列化
[Response结构体]
以上即为网络计算器程序的核心框架与关键流程。接下来,我们将详细讲解服务端与客户端各模块的具体设计与实现细节。
服务端
接下来梳理服务端的实现细节。首先明确,服务端与客户端之间采用 TCP 协议进行通信。服务端首先创建监听套接字,创建成功后将其绑定到指定的 IP 地址和端口,随后调用 listen 接口将套接字状态切换为 TCP_LISTEN 监听状态。
在实现上,我们采用 C++ 面向对象的设计方式,将服务端相关的操作封装为类的成员函数,以提升代码的可管理性。为此,定义一个 Tcpserver 类。需要注意的是,服务端与客户端的网络通信流程是相对固定和模式化的,主要包括:调用 socket 创建套接字、调用 bind 进行绑定、调用 listen 启动监听等。核心步骤就是依次调用这几个系统调用,并检查其返回值,若调用失败则关闭套接字并记录日志。
为了封装这些固定操作,我们专门实现了一个 sock 类。该类包含 socket 、 bind、 listen 等成员方法,其内部实现即调用相应的系统调用并检查返回值。此外, sock类内部维护套接字的文件描述符,并运用 RAII 思想,将套接字的生命周期交由该类管理——在析构函数中自动调用 close 接口关闭套接字。
sock类封装在 socket.hpp 头文件中。这样做的好处是,后续在编写服务端或客户端代码时,无需再手动调用系统接口并检查返回值,只需包含该头文件,定义 sock 对象,并调用相应的成员函数即可。
class sock
{
public:
sock()
: socketfd(-1)
{}
~sock()
{
if (socketfd >= 0)
{
::close(socketfd);
}
}
void socket()
{
socketfd = ::socket(AF_INET, SOCK_STREAM, 0);
if (socketfd < 0)
{
lg.logmessage(Fatal, "socket error");
socketfd = -1;
exit(Socket_Error);
}
lg.logmessage(info, "socket successfully");
}
void bind(std::string ip, uint16_t port)
{
// ... 实现绑定逻辑
}
void listen()
{
// ... 实现监听逻辑
}
//其他成员函数
// 禁止拷贝构造和赋值
sock(const sock&) = delete;
sock& operator=(const sock&) = delete;
private:
int socketfd;
};
需要注意的是,由于成员函数名与系统调用名称相同,为避免递归调用,在调用系统接口时需使用作用域限定符 :: 指定全局命名空间。
Tcpserver 类内部包含一个 sock 对象,以及 IP 地址、端口号以及布尔类型的监听标志和回调函数。其成员函数主要包括 init 和 start ,而回调函数则是用于在接收到客户端请求后所进行数据处理。 Tcpserver的构造函数接收监听套接字要绑定的 IP 地址、端口号以及服务回调函数,并将监听标志初始化为 false 。
using fun_t = std::function<response(const request&)>;
class TcpServer
{
public:
TcpServer(std::string _ip = _default, uint16_t _port = 8080, fun_t _callback = nullptr)
: ip(_ip)
, port(_port)
, callback(_callback)
, islistening(false)
{}
~TcpServer()
{
listen_socket.close();
}
// 其他成员函数...
private:
sock listen_socket;
std::string ip;
uint16_t port;
bool islistening;
fun_t callback;
};
关于 IP 地址的设置,由于一台设备可能有多个网络接口,若希望服务端能接收来自所有接口的客户端请求,应将 IP 地址设置为 "0.0.0.0" 。因此建议将服务端绑定的 IP 地址设为 "0.0.0.0" 。
接下来是 init 函数,其主要功能是创建监听套接字并将其绑定到指定 IP 和端口,即调用 listen_socket 的 socket 和 bind方法。
class TcpServer
{
public:
void init()
{
listen_socket.socket();
listen_socket.bind(ip, port);
}
// ...
};
start函数负责将套接字置于监听状态。若设置成功,则将监听标志设为 true ,并进入一个循环,不断调用 sock 对象的 accept 方法接收新的客户端连接请求。若当前没有客户端连接(即未完成三次握手),服务端会在此处阻塞;连接建立成功后, accept返回已连接套接字的文件描述符。
由于我们设计的是短连接服务——即每次连接只处理一个请求,之后立即断开连接,而不是保持长时通信——因此,服务端的主要职责是接受连接请求、完成三次握手,然后立即返回继续等待下一个连接,而不应在建立连接后陷入具体业务处理。
由此可见,建立连接和执行短服务是两个独立且可并行的过程。为了将两者解耦,我们将短服务处理逻辑放在独立的线程上下文中执行。为避免频繁创建和销毁线程带来的开销,这里引入线程池机制。线程池内部维护一个环形缓冲区与一组消费者线程:主线程(生产者)接收连接并生成任务,将其放入缓冲区;消费者线程从缓冲区取出任务并执行。
class threadpool
{
public:
static threadpool& getinstance()
{
static threadpool instance;
return instance;
}
void start()
{
for (int i = 0; i < Max_size; i++)
{
pthread_t tid;
pthread_create(&tid, NULL, handlertask, this);
}
}
void push(const Task& T)
{
// 任务入队逻辑
}
void pop()
{
// 任务出队逻辑
}
threadpool(const threadpool&) = delete;
threadpool& operator=(const threadpool&) = delete;
private:
threadpool(int max_num = max_size, int max_task_size = max_size)
:Max_size(max_num)
, c_index(0)
, p_index(0)
, Max_task_size(max_task_size)
{
q.resize(Max_task_size);
pthread_mutex_init(&mutex, NULL);
sem_init(&element, 0, 0);
sem_init(&space, 0, Max_task_size);
}
static void* handlertask(void* args)
{
threadpool* tp = (threadpool*)args;
while (1)
{
Task task = tp->pop();
task.run();
}
return NULL;
}
std::vector<Task> q;
int Max_size;
pthread_mutex_t mutex;
int Max_task_size;
int c_index;
int p_index;
sem_t element;
sem_t space;
};
这里的“任务”即 Task 对象,它接收一个已连接套接字描述符和回调函数。 Task的核心是 run 成员函数,其中定义了线程执行的逻辑:调用 read 读取客户端发送的字节流,分离并且解析协议头,反序列化还原为结构化数据,调用回调函数处理,再将结果序列化并封装协议头,最后通过 write 将字节流发送回客户端。
class Task
{
public:
Task()
: socketfd(-1)
, callback(nullptr)
{}
Task(int _socketfd, fun_t _callback)
: socketfd(_socketfd)
, callback(_callback)
{}
void run()
{
// 读取、解析、处理、响应的完整流程
}
private:
int socketfd;
fun_t callback;
};
另外,我们还可以选择将服务端守护进程化。上文已实现守护进程化的接口,其主要步骤包括:调用 fork 创建子进程,使父进程(原进程组组长)退出;子进程调用 setsid 脱离原会话,并不再关联任何控制终端;同时将工作目录切换至根目录(因其稳定性高,不易被删除)。因此, start函数接受一个布尔型参数 isdameon ,用于控制是否启用守护进程模式。
综上所述,完整的 start 方法流程如下:
- 检查
isdameon参数,若为true,则调用守护进程接口将当前进程守护进程化。 - 获取线程池单例对象,并调用其
start方法启动一批工作线程。 - 调用
sock对象的listen方法,将套接字切换至监听状态。 - 进入循环,不断调用
accept接收新连接。 - 每接收到一个新连接,创建一个
Task对象(包含已连接套接字描述符和回调函数),将其推入线程池的任务队列。 - 线程池中的工作线程取出任务,执行其
run方法处理请求。
class TcpServer
{
public:
// ...
void start(bool isdameon = false)
{
if (isdameon)
{
Daemon dae;
dae.daemon();
}
if (islistening)
{
lg.logmessage(warning, "server is already listening");
return;
}
threadpool& tp = threadpool::getinstance();
tp.start();
listen_socket.listen();
islistening = true;
lg.logmessage(info, "server start on ip: %s, port: %d", ip.c_str(), port);
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t clientlen = sizeof(client);
while (islistening)
{
int client_fd = listen_socket.accept(&client, &clientlen);
if (client_fd < 0)
{
if (errno == EINTR)
continue;
}
Task t(client_fd, callback);
tp.push(t);
}
}
// ...
};
整体执行流程可概括为以下模型:
主线程 (生产者) 线程池 (消费者)
│ │
│ accept() 获取新连接 │
│ │ │
│ ▼ │
│ 创建Task对象 │
│ (client_fd, 回调函数) │
│ │ │
│ └──推送到───▶ 任务队列
│ │
│ ▼
│ 线程取任务
│ 执行run()
│ 处理请求
│ close(connfd)
│
循环等待新连接 等待新任务
至此,我们已经梳理了服务端的大部分实现细节,但仍有部分核心内容尚未涉及,即 Task 的 run 方法、回调函数的实现,以及序列化与反序列化模块。接下来的内容将集中梳理这几个部分的实现。
序列化与反序列化
首先,我们来讨论序列化与反序列化的实现。根据上文的介绍,我们需要对实际传输的应用层数据(即消息部分)进行结构化处理。这里我们通过一个 request 结构体来描述要发送的数据,该结构体的字段构成了消息正文的内容。 request包含两个 int 类型的操作数和一个 char 类型的操作符。客户端不能直接发送 request 对应的内存字节流,而是需要按照约定的规则将其序列化,即转换为连续的字节流,再发送给服务端。
我们规定客户端与服务端之间采用文本序列化方式。具体来说,提取 request 结构体中的所有字段,将其转换为字符串,并拼接成一个完整字符串。字符串格式规定如下:
“data1 op data2\n”
其中,两个操作数与运算符之间以空格分隔,字符串末尾添加一个换行符。添加换行符的目的主要是便于观察和调试。因此,我们需要在request结构体中定义一个serialization 函数,该函数按照上述规则将各个字段拼接为一个完整的字符串,返回类型为std::string 。
const size_t HEADER_LEN = 4; // 表示头部长度
std::string space = " ";
std::string line_break = "\n";
class request
{
public:
// ...
std::string serialization() const
{
std::string out;
out += std::to_string(data1);
out += space;
out += op;
out += space;
out += std::to_string(data2);
out += line_break;
return out;
}
// ...
};
以上序列化得到的仅是消息正文。除了消息正文,我们还需要封装一个协议头。这里的协议头只包含一个字段,即消息正文的长度。我们将封装协议头的功能专门设计为Encode 函数,它会在已序列化的消息正文字符串前拼接一个表示长度的头部。因此,完整的应用层数据格式如下:
"len\ndata1 op data2\n"
在协议头后添加换行符同样是为了便于调试和观察。
Encode函数的实现逻辑如下:
- 检查输入字符串是否为空(即消息正文是否为空),若为空则直接返回
false。 - 若不为空,则获取消息正文的长度,并将其转换为字符串。
- 我们规定协议头的固定长度为 4 字节(不包括换行符)。因此,需检查转换后的长度字符串是否超过 4 字节,若超过则返回
false。这意味着消息正文的长度最大为 9999 字节。 - 若长度字符串不足 4 字节,则需要在前面填充前导零,使其达到 4 字节。我们通过生成相应数量的
'0'字符实现填充。 - 在填充后的长度字符串后添加换行符,形成完整的协议头,再与消息正文拼接。
- 该函数参数为输出型参数
std::string& in。由于内部构建的input对象为局部变量,函数返回前可通过std::move将其资源转移给输出参数,避免不必要的拷贝,最后返回true表示封装成功。
bool Encode(std::string& in)
{
if (in.empty())
{
return false;
}
std::string input;
uint16_t len = in.size();
std::string head = std::to_string(len);
if (head.size() > HEADER_LEN)
{
return false;
}
int fill_len = HEADER_LEN - head.size();
std::string fill_string;
fill_string.resize(fill_len, '0');
head = fill_string + head;
input += head;
input += line_break;
input += in;
in = std::move(input);
return true;
}
在介绍了 Encode函数及其对协议头的封装方法后,相应的,我们同样需要了解如何从接收数据中分离协议头,为此我们引入 Decode函数。 Decode函数的功能是解析并剥离协议头。协议头固定长度为4字节,若包含换行符则为5字节。由于序列化过程采用 文本模式,字节流会先转换为字符串,再传递给 Decode函数进行处理。
Decode函数接收完整的应用层数据,首先检查其总长度是否小于5字节( HEADER_LEN+1)。若小于该长度,说明发送端数据不完整,函数返回 false,因为有效数据至少需要5字节。接着,函数会验证第5个字节是否为换行符(‘\n’),以确认协议格式正确性。若不是换行符,同样返回false。格式检查通过后,函数通过substr截取前4个字节作为头部字符串,并调用 std::stoi(该函数会自动忽略前导0)将其转换为无符号32位整数,以此获取消息正文的长度。随后再次调用substr,根据消息长度分离出正文部分,并在函数内部以局部对象data保存。该函数的参数为输出型引用参数,解析完成后,通过std::move将data的资源所有权转移给输出参数,从而避免不必要的拷贝操作。
bool Decode(std::string& in)
{
if(in.size() < HEADER_LEN + 1)
{
return false;
}
std::string head = in.substr(0, HEADER_LEN);
if(in[HEADER_LEN] != '\n')
{
return false;
}
uint32_t mes_len = std::stoi(head);
if(in.size() < HEADER_LEN + 1 + mes_len)
{
return false;
}
std::string data = in.substr(HEADER_LEN + 1, mes_len);
in = std::move(data);
return true;
}
以上,我们介绍了协议头的封装与分离函数( Encode与 Decode)以及Request的序列化函数。接下来讲解Request的反序列化函数。
在调用Request的反序列化函数前,必须先通过 Decode函数分离协议头,得到消息正文。获得正文后,调用Request的 Deserialization函数即可还原发送方发出的结构化数据。
Request的 Deserialization函数负责解析消息正文,按照约定格式提取操作数与运算符,并初始化Request对象的对应字段。约定格式如下:
"data1 op data2\n"
函数首先检查输入字符串是否为空,若为空则返回false。若非空,进一步处理:若字符串末尾存在换行符,则将其移除。接着,通过find定位第一个空格的位置,若不存在空格(返回 npos),则格式错误,返回false。然后,通过 substr截取第一个操作数字符串,并调用 std::stoi转换为整型。随后,通过 rfind从后向前查找第二个空格的位置,并与第一个空格位置比较:若两者相同,说明字符串中仅含一个空格,格式错误,返回false。若不同,则继续截取第二个操作数字符串并转换为整型。
最后,检查两个空格之间是否仅相差一个字节,若是,则该字节即为运算符,否则返回false。最终定位运算符并初始化Request的op字段。
bool Deserialization(const std::string& in)
{
if(in.empty())
{
return false;
}
std::string input = in;
if(input.back() == '\n')
{
input.pop_back();
}
size_t pos1 = input.find(space);
if (pos1 == std::string::npos)
{
return false;
}
std::string _data1 = input.substr(0, pos1);
data1 = std::stoi(_data1);
size_t pos2 = input.rfind(space);
if (pos2 == pos1)
{
return false;
}
std::string _data2 = input.substr(pos2 + 1);
data2 = std::stoi(_data2);
if (pos1 + 2 != pos2)
{
return false;
}
// 提取运算符并初始化op字段
op = input[pos1 + 1];
return true;
}
流程示意:
序列化:request对象 → Encode → 发送
反序列化:接收 → Decode → 反序列化 → Request对象
上文介绍了 request构体的序列化与反序列化。相应地, response结构体也具备序列化与反序列化的能力。根据前文, response结构体包含两个字段:计算结果( result)与状态码( code)。本例采用文本模式进行初始化,因此需要提取 response结构体的各个字段,并将其拼接为一个完整的字符串。我们规定序列化规则(即字符串格式)如下:
"result code\n"
注意,此处的序列化仅针对消息正文。由于字符串格式已经确定, response结构体的serialization函数的实现原理便很直接:将 response结构体的各个字段转换为字符串,再拼接为一个完整的字符串。该函数的返回类型为 std::string,其内容即为拼接后的字符串。
class response
{
public:
// ...
std::string serialization() // 格式:"result code\n"
{
std::string out;
out += std::to_string(result);
out += space;
out += std::to_string(code);
out += line_break;
return out;
}
// ...
};
最后是 response的反序列化函数 Deserialization。在调用该函数前,需先调用 Decode函数分离出消息正文。 Deserialization函数接收消息正文对应的连续字节流(即字符串),并按照双方约定的格式,将其还原为结构化的数据。
Deserialization首先判断字符串是否为空,若为空则直接返回 false。若非空,则检查字符串末尾是否为换行符,若是则将其去除。接着,通过find函数定位空格字符的位置,并利用 substr分离出 result字符串与 code字符串,再将它们转换为整型,最后完成对应字段的赋值。
class response
{
public:
bool Deserialization(const std::string& in)
{
if(in.empty())
{
return false;
}
std::string input = in;
if(input.back() == '\n')
{
input.pop_back();
}
size_t pos = input.find(space);
if (pos == std::string::npos)
{
return false;
}
std::string _result = input.substr(0, pos);
result = std::stoi(_result);
std::string _code = input.substr(pos + 1);
code = std::stoi(_code);
return true;
}
};
回调函数
接下来梳理回调函数的设计。对于服务端而言,在接收到客户端发送的字节流后,会先调用解码函数(Decode)分离出消息正文,再对正文进行反序列化,还原为发送方发送的结构化数据,即一个 request 结构体。随后进入数据处理阶段,该阶段本质上对应回调函数的执行过程。回调函数的上下文即为数据处理的具体内容,因此数据处理环节即是调用该回调函数。
此处我们将回调函数设计为一个包装器,其接收一个request 对象作为参数,并返回一个response 对象。
using fun_t =std::function<response(const request&)>;
该可调用对象的主要逻辑是:从request 对象中提取操作数和运算符字段,根据运算符执行相应的算术运算,判断运算结果是否正确,并最终构造并初始化一个 response 结构体,其中包含计算结果与状态信息。
为封装运算逻辑,我定义了一个 Calculator 类,其中包含一个 Calculate 成员函数。该函数根据 request 对象中的运算符进入对应的 switch-case 分支执行算术运算。对于除法和取模运算,需额外判断除数是否为零;若为零,则设置相应的非零状态码以标识运算错误。
enum
{
Div_Error = 1,
Mod_Error,
Unknown_Error,
Overflow_Error,
};
class Calculator
{
public:
response Calculate(const Request& req)
{
response res;
switch (req.op)
{
case '+':
res.result = req.data1 + req.data2;
break;
case '-':
res.result = req.data1 - req.data2;
break;
case '*':
res.result = req.data1 * req.data2;
break;
case '/':
if (req.data2 == 0)
{
res.code = Div_Error;
res.result = 0;
break;
}
res.result = req.data1 / req.data2;
break;
case '%':
if (req.data2 == 0)
{
res.code = Mod_Error;
res.result = 0;
break;
}
res.result = req.data1 % req.data2;
break;
default:
res.code = Unknown_Error;
res.result = 0;
}
return res;
}
};
需要注意的是, Calculate 是类的成员函数,而 Tcpserver 类中设定的回调函数是一个可调用对象包装器。包装器可接受函数指针、函数对象或 lambda 表达式等可调用实体,但无法直接接受成员函数,因为调用成员函数需隐式传递 this 指针。因此,这里需借助 std::bind 这一函数适配器将成员函数适配为符合要求的可调用对象。
当然,也可将 Calculate 定义为全局函数以避免该问题。但将其封装在类中,有利于代码组织与管理。 std::bind 的作用包括固定可调用对象的参数以及重新排列参数顺序,从而提高调用的灵活性。
以下简要介绍 std::bind 的基本机制,以便不熟悉的读者理解。 std::bind的首要功能是参数绑定:若可调用对象具有多个参数,其中某些参数值固定,则可通过 bind 将其固定,调用时无需再传入。 std::bind的第一个参数为可调用对象,其后参数依次对应目标可调用对象的各个形参,可为具体值或占位符(std::placeholders::_1、 _2 等),且数量必须与目标可调用对象的形参数量一致。
例如,对于以下函数:
void print(int x, int y, int z)
{
std::cout << "x: " << x << " y:" << y << " z:" << z << std::endl;
}
可使用 bind 固定所有参数:
auto F = std::bind(print, 1, 2, 3);
F(); // 输出 x: 1 y: 2 z: 3
此时调用 F 时无需传递参数,若传递也会被忽略。参数对应关系为:
std::bind(print, 1, 2, 3);
| | |
v v v
print(int x, int y, int z);
若使用占位符,则调用返回的可调用对象时需传入对应参数。占位符 _1 表示调用时的第一个实参, _2 表示第二个实参,依此类推。占位符的下标决定了调用 std::bind 所返回的可调用对象时,提供的第几个实参会对应到该占位符;而占位符在 std::bind 参数列表中的顺序与位置,则决定了该实参在最终调用目标函数时所填入的形参位置。。需注意占位符编号不得超过目标函数的参数个数,而调用时可传递多于占位符数量的实参,多余参数会被忽略。

对于成员函数,情况较为特殊,因为其隐含一个 this 指针参数。因此,在绑定时,第一个额外参数需传入该类对象的指针(或引用),后续再接成员函数原本的参数列表。因此,在构造 Tcpserver 时,可先创建Calculator 对象,再通过 std::bind 绑定成员函数及对象指针,生成适配后的可调用对象,并传入 Tcpserver 作为回调函数:
Calculator cal;
TcpServer server(_default, port,
std::bind(&Calculator::Calculate, &cal, std::placeholders::_1));
这样, Task 的 run 方法在调用回调函数时,只需传递一个 Request 对象, bind 适配后的可调用对象会正确调用 cal.Calculate() 并传入该参数。
Task的run方法
在上文介绍了回调函数、序列化与反序列化的实现之后,接下来即可完善 Task 类的 run 方法。该方法实现的是一个短连接服务处理流程,主要包括:接收客户端发送的字节流,从中分离出消息正文,反序列化得到结构化的请求数据,接着处理数据,并将处理结果序列化,封装报头后发送回客户端。
具体而言,在 Task::run() 中,首先调用 read 接口读取字节流并存入缓冲区。之后需分离出消息正文,这里调用 Decode 函数完成。由于 Decode 及后续反序列化函数均接收 std::srting 对象,而缓冲区是字符数组,因此需将字符数组转为 std::string 。接着检查 Decode 的返回值,若为 false ,表明客户端发送的数据有误,此时打印日志、关闭套接字并退出方法;若正常,则调用 Deserialization 函数得到 request 结构体。随后调用注册的回调函数,并将解析得到的 requeset 传入,获取其返回的 response 对象。接着对该 response 进行序列化,再调用 Enocde 封装报头,最后通过 write 函数将结果发送回客户端。发送后需检查 write 的返回值,若小于 0,则记录日志、关闭套接字并退出。
class Task
{
public:
//...
void run()
{
if(socketfd<0)
{
lg.logmessage(Fatal,"socketfd is invalid");
return;
}
char buffer[BUFFER_SIZE];
int bytes_read=read(socketfd, buffer, sizeof(buffer));
if (bytes_read < 0)
{
lg.logmessage(Fatal, "read error");
close(socketfd);
return;
}
else if (bytes_read == 0)
{
lg.logmessage(info, "client disconnected");
close(socketfd);
return;
}
buffer[bytes_read]='\0';
request req;
std::string input(buffer,bytes_read);
bool de_code=Decode(input);
if(de_code==false)
{
lg.logmessage(warning,"Decode error");
close(socketfd);
return;
}
bool des=req.Deserialization(input);
if (des == false)
{
lg.logmessage(Fatal, "deserialization error");
close(socketfd);
return;
}
response res=callback(req);
std::string result = res.serialization();
bool en_code=Encode(result);
if(en_code==false)
{
lg.logmessage(warning,"Encode error");
close(socketfd);
return;
}
int bytes_write=write(socketfd, result.c_str(), result.size());
if (bytes_write < 0)
{
lg.logmessage(Fatal, "write error");
close(socketfd);
return;
}
lg.logmessage(info, "send message successfully");
close(socketfd);
lg.logmessage(info,"thread quit");
}
};
ServerCal.cpp
根据上文,我们已经完成了 Tcpserver类与线程池等模块的实现,接下来将编写服务端的主程序。在Linux系统中,进程通常通过命令行启动,并借助命令行参数将所需的配置信息传递给进程。在本例中,我们需要传递的配置是监听套接字要绑定的IP地址与端口号。不过,我们已将服务端监听套接字绑定的IP地址固定设置为 "0.0.0.0",因此只需通过命令行传递一个端口号即可。
Bash接收我们输入的命令行指令,该指令本质上是一个字符串,其中命令名与参数之间以空格分隔。Bash会以空格为分隔符,将字符串拆分为命令名和参数部分,并存入字符串数组,再传递给进程。因此,服务端主线程首先要做的就是获取该字符串数组,并检查参数个数。此处,参数总数应为2(包含命令名本身与端口号参数)。若参数个数不为2,则打印错误日志并退出程序。
若参数个数正确,则解析第二个参数(即端口号),调用 std::stoi将其从字符串转换为整型。接着,创建 Tcpserver对象,将IP地址、端口号及相应的回调函数传入该对象。最后,依次调用 Tcpserver对象的init方法与 start方法,启动服务。
#include"Tcpserver.h"
#include"ServerCal.hpp"
extern log lg;
void usage(const char* programname)
{
lg.logmessage(Fatal, "usage error:%s <port>", programname);
}
int main(int argc, char* argv[])
{
if (argc != 2)
{
usage(argv[0]);
exit(Usage_Error);
}
uint16_t port = std::stoi(argv[1]);
Calculator cal;
Tcpserver server(_default,port,std::bind(&Calculator::Calculate,&cal,std::placeholders::_1));
server.init();
server.start();
return 0;
}
服务端源码
Socket.hpp:
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string>
#include<cstring>
#include<cstdlib>
#include"log.hpp"
extern log lg;
enum
{
Socket_Error = 1,
Bind_Error,
Listen_Error,
Accept_Error,
Connect_Error,
Usage_Error,
};
class sock
{
public:
sock()
:socketfd(-1)
{
}
~sock()
{
if(socketfd>=0)
{
::close(socketfd);
}
}
/**
* 创建一个TCP套接字
* 使用AF_INET地址族和SOCK_STREAM类型创建流式套接字
* 如果创建失败,记录错误日志并退出程序
*/
void socket()
{
// 调用系统socket函数创建套接字
// AF_INET: IPv4协议
// SOCK_STREAM: TCP套接字
// 0: 自动选择合适的协议类型
socketfd= ::socket(AF_INET,SOCK_STREAM,0);
// 检查套接字是否创建成功
if (socketfd < 0)
{
// 记录致命错误日志
lg.logmessage(Fatal, "socket error");
// 设置套接字描述符为无效值
socketfd = -1;
// 退出程序,错误码为Socket_Error
exit(Socket_Error);
}
// 记录成功创建套接字的信息日志
lg.logmessage(info, "socket successfully");
}
void bind(std::string ip, uint16_t port)
{
// 检查Socket是否已创建
if (socketfd < 0)
{
// 记录致命错误日志:Socket未创建
lg.logmessage(Fatal, "socket not created");
// 退出程序,错误码为Socket_Error
exit(Socket_Error);
}
// 创建并初始化服务器地址结构体
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
// 设置地址族为IPv4
server.sin_family = AF_INET;
// 将端口号从主机字节序转换为网络字节序
server.sin_port = htons(port);
// 处理IP地址绑定
if (ip == "0.0.0.0")
{
// 绑定所有可用的网络接口(INADDR_ANY)
server.sin_addr.s_addr = INADDR_ANY;
}
else
{
// 将点分十进制的IP字符串转换为网络字节序
if (inet_pton(AF_INET, ip.c_str(), &server.sin_addr) <= 0)
{
// IP转换失败,记录错误日志
lg.logmessage(Fatal, "inet_pton fail");
// 关闭socket
::close(socketfd);
// 重置socket描述符
socketfd = -1;
// 退出程序,错误码为Bind_Error
exit(Bind_Error);
}
}
// 获取地址结构体大小
socklen_t serverlen = sizeof(server);
// 调用系统bind函数进行绑定
int n = ::bind(socketfd, (struct sockaddr*)&server, serverlen);
if (n < 0)
{
// 绑定失败,记录错误日志
lg.logmessage(Fatal, "bind error");
// 关闭socket
::close(socketfd);
// 重置socket描述符
socketfd = -1;
// 退出程序,错误码为Bind_Error
exit(Bind_Error);
}
// 绑定成功,记录信息日志
lg.logmessage(info, "bind successfully");
}
/**
* 监听函数,用于开始监听客户端连接请求
* 该函数首先检查socket是否已创建,然后调用listen函数开始监听
* 如果出现错误,会记录日志并进行相应处理
*/
void listen()
{
// 检查socket是否已创建,如果socketfd小于0表示socket未创建
if (socketfd < 0)
{
// 记录致命错误日志,提示socket未创建
lg.logmessage(Fatal,"socket not created");
// 退出程序,错误码为Socket_Error
exit(Socket_Error);
}
// 调用系统listen函数开始监听,第二个参数5表示最大连接队列长度
int n = ::listen(socketfd, 5);
// 检查listen函数返回值,如果小于0表示监听失败
if (n < 0)
{
// 记录致命错误日志,提示监听错误
lg.logmessage(Fatal, "listen error");
// 关闭socket文件描述符
::close(socketfd);
// 将socketfd重置为-1,表示socket无效
socketfd = -1;
// 退出程序,错误码为Listen_Error
exit(Listen_Error);
}
// 记录信息日志,提示监听成功
lg.logmessage(info,"listen successfully");
}
/** 接受客户端连接请求
*/
int accept(struct sockaddr_in* client, socklen_t* clientlen)
{
// 检查服务器socket是否已创建
if (socketfd < 0)
{
// 记录致命错误日志:Socket未创建
lg.logmessage(Fatal, "socket not created");
// 退出程序,错误码为Socket_Error
exit(Socket_Error);
}
// 调用系统accept函数接受客户端连接
// socketfd: 服务器监听的socket描述符
// client: 用于存储客户端地址信息的结构体
// clientlen: 客户端地址结构体的长度
int client_fd = ::accept(socketfd, (struct sockaddr*)client, clientlen);
// 检查accept是否成功
if (client_fd < 0)
{
// 接受连接失败,记录错误日志
lg.logmessage(Fatal, "accept error");
// 返回错误码-1
return -1;
}
// 接受连接成功,记录信息日志
lg.logmessage(info, "accept successfully");
// 返回新的客户端socket描述符
return client_fd;
}
/**
* 连接到服务器的函数
*/
void connect(struct sockaddr_in* server,socklen_t serverlen)
{
// 检查socket是否有效
if (socketfd < 0)
{
// 记录socket未创建的致命错误日志
lg.logmessage(Fatal,"socket not created");
// 退出程序,错误码为Socket_Error
exit(Socket_Error);
}
// 尝试连接到服务器
int n = ::connect(socketfd,(struct sockaddr*)server,serverlen);
// 检查连接是否成功
if (n < 0)
{
// 记录连接错误的致命日志
lg.logmessage(Fatal, "connect error");
// 关闭socket
::close(socketfd);
// 将socket描述符重置为无效值
socketfd = -1;
// 退出程序,错误码为Connect_Error
exit(Connect_Error);
}
// 记录连接成功的日志
lg.logmessage(info, "connect successfully");
}
void close()
{
if(socketfd>=0)
{
::close(socketfd);
socketfd=-1;
}
}
sock(const sock&)=delete;
sock& operator=(const sock&)=delete;
private:
int socketfd;
};
Tcpserver.h:
#pragma once
#include"Socket.hpp"
#include"Threadpool.h"
#include"protocol.hpp"
#include<functional>
#include<sys/types.h>
#include<sys/socket.h>
#include<unistd.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<cerrno>
#include"Task.hpp"
#include"daemon.hpp"
#include<signal.h>
extern log lg;
std::string _default = "0.0.0.0";
using fun_t =std::function<response(const request&)>;
class Tcpserver
{
public:
Tcpserver(std::string _ip = _default, uint16_t _port=8080,fun_t _callback=nullptr)
:ip(_ip)
, port(_port)
,callback(_callback)
,islistening(false)
{
}
~Tcpserver()
{
listen_socket.close();
}
/**
* 初始化函数
* 用于创建并绑定监听套接字
*/
void init() // 初始化函数定义
{
listen_socket.socket(); // 创建套接字
listen_socket.bind(ip,port); // 绑定IP地址和端口号到套接字
}
/**
* 启动服务器函数
* isdameon 是否以守护进程方式运行,默认为false
*/
void start(bool isdameon=false)
{
// 如果设置为守护进程模式,则创建守护进程
if(isdameon)
{
Daemon dae; // 创建守护进程对象
dae.daemon(); // 调用守护进程方法
}
// 检查服务器是否已经在监听状态
if(islistening)
{
// 如果已在监听,记录警告日志并返回
lg.logmessage(warning,"server is already listening");
return;
}
// 获取线程池单例并启动
threadpool& tp = threadpool::getinstance();
tp.start();
// 开始监听
listen_socket.listen();
islistening = true;
// 记录服务器启动日志,显示IP和端口
lg.logmessage(info,"server start on ip: %s ,port :%d",ip.c_str(),port);
// 初始化客户端地址结构
struct sockaddr_in client;
memset(&client, 0, sizeof(client));
socklen_t clientlen = sizeof(client);
// 主循环,持续监听客户端连接
while (islistening)
{
// 接受新的客户端连接
int client_fd = listen_socket.accept(&client,&clientlen);
// 处理接受连接可能出现的错误
if(client_fd<0)
{
// 如果是被信号中断,则继续循环
if(errno==EINTR)
continue;
}
// 创建任务对象并添加到线程池
Task t(client_fd, callback);
tp.push(t);
}
}
private:
sock listen_socket;
std::string ip;
uint16_t port;
bool islistening;
fun_t callback;
};
Threadpool.h:
#pragma once
#include<pthread.h>
#include<semaphore.h>
#include<string>
#include<vector>
#include<sys/types.h>
#include"Task.hpp"
#define max_size 10
class threadpool
{
public:
// 获取线程池单例对象的静态成员函数
// 使用静态局部变量实现线程安全的单例模式
static threadpool& getinstance()
{
// 声明一个静态的线程池实例,该实例会在第一次调用此函数时创建
// 之后每次调用都会直接返回这个已存在的实例
static threadpool instance;
// 返回静态实例的引用
return instance;
}
/**
* 启动函数,用于创建多个线程执行任务
* 该函数会创建Max_size个线程,每个线程都执行handlertask函数
*/
void start()
{
// 循环创建Max_size个线程
for (int i = 0; i < Max_size; i++)
{
// 声明线程ID变量
pthread_t tid;
// 创建线程,参数包括线程ID、线程属性、线程处理函数和线程参数
// this作为参数传递给handlertask函数,表示当前对象实例
pthread_create(&tid, NULL, handlertask, this);
}
}
/**从任务队列中弹出一个任务 */
Task pop()
{
sem_wait(&element); // 等待有任务的信号量,如果没有任务则阻塞
pthread_mutex_lock(&mutex); // 加锁,保证操作的原子性
Task data = q[c_index]; // 获取当前索引位置的任务
c_index = (c_index + 1) % Max_task_size; // 更新消费索引,循环利用队列空间
pthread_mutex_unlock(&mutex); // 解锁
sem_post(&space); // 释放空间信号量,表示队列中多了一个空闲位置
return data; // 返回获取的任务
}
/*向任务队列中添加一个任务的函数*/
void push(const Task& T)
{
sem_wait(&space); // 等待空间信号量,确保队列有空间存放新任务
q[p_index] = T; // 将任务存入队列的当前位置
p_index = (p_index + 1) % Max_task_size; // 更新写入位置索引,实现循环队列
sem_post(&element); // 释放元素信号量,通知队列中有新任务可取
}
~threadpool()
{
pthread_mutex_destroy(&mutex);
sem_destroy(&element);
sem_destroy(&space);
}
threadpool(const threadpool&) = delete;
threadpool& operator=(const threadpool&) = delete;
private:
threadpool(int max_num = max_size, int max_task_size = max_size)
:Max_size(max_num)
, c_index(0)
, p_index(0)
, Max_task_size(max_task_size)
{
q.resize(Max_task_size);
pthread_mutex_init(&mutex, NULL);
sem_init(&element, 0, 0);
sem_init(&space, 0, Max_task_size);
}
/*线程池中的工作线程执行函数
*/
static void* handlertask(void* args)
{
// 将传入的参数转换为线程池指针
threadpool* tp = (threadpool*)args;
// 线程无限循环,持续从任务队列中获取并执行任务
while (1)
{
// 从线程池的任务队列中获取一个任务
Task task = tp->pop();
// 执行获取到的任务
task.run();
}
// 理论上不会执行到这里,因为循环是无限的
return NULL;
}
std::vector<Task> q;
int Max_size;
pthread_mutex_t mutex;
int Max_task_size;
int c_index;
int p_index;
sem_t element;
sem_t space;
};
ServerCal.hpp:
#pragma once
#include<climits>
#include"protocol.hpp"
enum
{
Div_Error=1,
Mod_Error,
Unknown_Error,
Overflow_Error,
};
class Calculator
{
public:
/* 计算函数,根据请求中的操作符和操作数进行相应的计算,并返回结果*/
response Calculate(const request& req)
{
response res; // 创建响应对象,用于存储计算结果或错误信息
res.code=0; // 初始化错误码为0,表示无错误
// 根据请求中的操作符进行相应的计算
switch (req.op)
{
case '+': // 加法操作
res.result = req.data1 + req.data2;
break;
case '-': // 减法操作
res.result = req.data1 - req.data2;
break;
case '*' : // 乘法操作
res.result=req.data1*req.data2;
break;
case '/': // 除法操作
// 检查除数是否为0,防止除零错误
if(req.data2==0)
{
res.code=Div_Error; // 设置错误码为除零错误
res.result=0;
break;
}
res.result=req.data1/req.data2;
break;
case '%': // 取模操作
// 检查除数是否为0,防止取模零错误
if(req.data2==0)
{
res.code=Mod_Error; // 设置错误码为取模零错误
res.result=0;
break;
}
res.result=req.data1%req.data2;
break;
default: // 未知的操作符
res.code=Unknown_Error; // 设置错误码为未知错误
res.result=0;
}
return res; // 返回计算结果或错误信息
}
};
protocol.hpp:
#pragma once
#include<string>
#include<cstring>
#include<arpa/inet.h>
const size_t HEADER_LEN = 4;//表示头部长度
std::string space = " ";
std::string line_break = "\n";
/*对输入字符串进行编码,添加固定长度的头部信息 */
bool Encode(std::string& in) //"len/ndata1 op data\n"
{
// 检查输入是否为空
if(in.empty())
{
return false;
}
std::string input; // 用于存储最终的编码结果
// 获取原始数据的长度
uint16_t len = in.size();
// 将长度转换为字符串
std::string head = std::to_string(len);
// 检查长度字符串是否超过固定头部大小
if(head.size() > HEADER_LEN)
{
return false;
}
// 计算需要填充的'0'的个数
int fill_len = HEADER_LEN - head.size();
// 创建填充字符串
std::string fill_string;
fill_string.resize(fill_len, '0');
// 组装头部:填充字符串 + 长度字符串
head = fill_string + head;
// 按顺序组装最终的编码字符串
input += head; // 添加头部信息
input += line_break; // 添加换行符
input += in; // 添加原始数据
// 使用move语义将编码后的数据移回给输入参数
in = std::move(input);
return true;
}
/* 解码函数,从输入字符串中提取数据*/
bool Decode(std::string& in)
{
// 检查输入字符串长度是否足够包含头部和一个换行符
if(in.size()<HEADER_LEN+1)
{
return false;
}
// 提取头部信息
std::string head=in.substr(0,HEADER_LEN);
// 检查头部后的第一个字符是否为换行符
if(in[HEADER_LEN]!='\n')
{
return false;
}
// 将头部转换为整型,表示数据长度
uint32_t mes_len=std::stoi(head);
// 检查输入字符串长度是否足够包含头部、换行符和实际数据
if(in.size()<HEADER_LEN+1+mes_len)
{
return false;
}
// 提取实际数据部分
std::string data=in.substr(HEADER_LEN+1,mes_len);
// 将输入字符串修改为提取出的数据,使用move避免不必要的拷贝
in=std::move(data);
return true;
}
class request
{
public:
request(int _data1,int _data2,char _op)
:data1(_data1)
,data2(_data2)
,op(_op)
{
}
request()
{
}
/**
* 序列化函数,将对象的数据转换为字符串格式
* 返回格式为"data1 op data\n"的字符串*/
std::string serialization() const //"data1 op data\n"
{
std::string out; // 用于存储序列化结果的字符串
out += std::to_string(data1); // 将data1转换为字符串并添加到结果中
out += space; // 添加空格分隔符
out += op; // 添加操作符
out += space; // 添加空格分隔符
out += std::to_string(data2); // 将data2转换为字符串并添加到结果中
out += line_break; // 添加换行符
return out; // 返回序列化后的字符串
}
/* 反序列化函数,将输入的字符串解析为数据成员*/
bool Deserialization(const std::string& in)
{
// 检查输入字符串是否为空
if(in.empty())
{
return false;
}
// 复制输入字符串,避免修改原始数据
std::string input=in;
// 如果字符串末尾有换行符,则移除它
if(input.back()=='\n')
{
input.pop_back();
}
// 查找第一个空格的位置
size_t pos1 = input.find(space);
// 如果没有找到空格,则返回false
if (pos1 == std::string::npos)
{
return false;
}
// 提取第一个数据部分并转换为整数
std::string _data1 = input.substr(0, pos1); //第二个参数是读取的长度,>不是结束位置!
data1 = std::stoi(_data1);
// 查找最后一个空格的位置
size_t pos2 = input.rfind(space);
// 如果最后一个空格和第一个空格位置相同,说明格式错误
if (pos2 == pos1)
{
return false;
}
// 提取第二个数据部分并转换为整数
std::string _data2 = input.substr(pos2+1);
data2 = std::stoi(_data2);
// 检查操作符位置是否正确(应该在两个数据之间)
if (pos1 + 2 != pos2)
{
return false;
}
// 提取操作符
op = input[pos1 + 1];
// 解析成功,返回true
return true;
}
public:
int data1;
int data2;
char op;
};
class response
{
public:
response(int _result,int _code)
:result(_result)
,code(_code)
{
}
response()
:result(0)
,code(0)
{
}
/*序列化函数,将结果和状态码转换为字符串格式*/
std::string serialization() //"result code\n"
{
std::string out; // 用于存储序列化结果的字符串
out += std::to_string(result); // 将结果转换为字符串并添加到out中
out += space; // 添加空格分隔符
out += std::to_string(code); // 将代码转换为字符串并添加到out中
out += line_break; // 添加换行符
return out; // 返回序列化后的字符串
}
/* 反序列化函数,将输入的字符串解析为result和code两个整数值*/
bool Deserialization(const std::string& in)
{
// 检查输入字符串是否为空
if(in.empty())
{
return false;
}
// 创建输入字符串的副本,以便修改
std::string input=in;
// 如果字符串末尾有换行符,则移除它
if(input.back()=='\n')
{
input.pop_back();
}
// 查找空格的位置
size_t pos = input.find(space);
// 如果找不到空格,则返回false
if (pos == std::string::npos)
{
return false;
}
// 提取空格前的子字符串并转换为整数,存储在result中
std::string _result = input.substr(0, pos);
result = std::stoi(_result);
// 提取空格后的子字符串并转换为整数,存储在code中
std::string _code = input.substr(pos+1);
code = std::stoi(_code);
return true;
}
public:
int result;
int code;
};
Task.hpp:
#pragma once
#include<functional>
#include<string>
#include<sys/types.h>
#include<unistd.h>
#include"protocol.hpp"
#include"ServerCal.hpp"
#include"log.hpp"
#define BUFFER_SIZE 1024
using fun_t=std::function<response(const request&)>;
extern log lg;
class Task
{
public:
Task()
:socketfd(-1)
,callback(nullptr)
{
}
Task(int _socketfd, fun_t _callback)
:socketfd(_socketfd)
,callback(_callback)
{
}
/**
* 运行函数,处理客户端请求
* 该函数负责从socket读取数据,解码,反序列化,
* 处理请求,序列化,编码,最后将响应发送回客户端
*/
void run()
{
// 检查socket描述符是否有效
if(socketfd<0)
{
lg.logmessage(Fatal,"socketfd is invalid");
return;
}
// 定义缓冲区并从socket读取数据
char buffer[BUFFER_SIZE];
int bytes_read=read(socketfd, buffer, sizeof(buffer));
// 处理读取错误的情况
if (bytes_read < 0)
{
lg.logmessage(Fatal, "read error");
close(socketfd);
return;
}
// 处理客户端断开连接的情况
else if (bytes_read == 0)
{
lg.logmessage(info, "client disconnected");
close(socketfd);
return;
}
// 确保字符串以null结尾
buffer[bytes_read]='\0';
// 创建请求对象并处理数据
request req;
std::string input(buffer,bytes_read);
// 解码输入数据
bool de_code=Decode(input);
if(de_code==false)
{
lg.logmessage(warning,"Decode error");
close(socketfd);
return;
}
// 反序列化请求数据
bool des=req.Deserialization(input);
if (des == false)
{
lg.logmessage(Fatal, "deserialization error");
close(socketfd);
return;
}
// 处理请求并获取响应
response res=callback(req);
std::string result = res.serialization();
// 编码响应结果
bool en_code=Encode(result);
if(en_code==false)
{
lg.logmessage(warning,"Encode error");
close(socketfd);
return;
}
// 将响应结果发送回客户端
int bytes_write=write(socketfd, result.c_str(), result.size());
if (bytes_write < 0)
{
lg.logmessage(Fatal, "write error");
close(socketfd);
return;
}
// 记录成功发送消息的信息
lg.logmessage(info, "send message successfully");
// 关闭socket连接
close(socketfd);
// 记录线程退出的信息
lg.logmessage(info,"thread quit");
}
private:
int socketfd;
fun_t callback;
};
log.hpp:
#pragma once
#include<iostream>
#include<string>
#include<time.h>
#include<stdarg.h>
#include<fcntl.h>
#define SIZE 1024
#define screen 0
#define File 1
#define ClassFile 2
enum
{
info,
debug,
warning,
Fatal,
};
class log
{
private:
std::string memssage;
int method;
public:
log(int _method = File)
:method(_method)
{
}
void logmessage(int leval, char* format, ...)
{
char* _leval;
switch (leval)
{
case info:
_leval = "info";
break;
case debug:
_leval = "debug";
break;
case warning:
_leval = "warning";
break;
case Fatal:
_leval = "Fatal";
break;
}
char timebuffer[SIZE];
time_t t = time(NULL);
struct tm* localTime = localtime(&t);
snprintf(timebuffer, SIZE, "[%d-%d-%d-%d:%d]", localTime->tm_year + 1900, localTime->tm_mon + 1, localTime->tm_mday, localTime->tm_hour, localTime->tm_min);
char rightbuffer[SIZE];
va_list arg;
va_start(arg, format);
vsnprintf(rightbuffer, SIZE, format, arg);
char finalbuffer[2 * SIZE];
int len=snprintf(finalbuffer, sizeof(finalbuffer), "[%s]%s:%s\n", _leval, timebuffer, rightbuffer);
int fd;
switch (method)
{
case screen:
std::cout << finalbuffer;
break;
case File:
fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd >= 0)
{
write(fd, finalbuffer, len);
close(fd);
}
break;
case ClassFile:
switch (leval)
{
case info:
fd = open("log/info.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
write(fd, finalbuffer, sizeof(finalbuffer));
break;
case debug:
fd = open("log/debug.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
write(fd, finalbuffer, sizeof(finalbuffer));
break;
case warning:
fd = open("log/Warning.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
write(fd, finalbuffer, sizeof(finalbuffer));
break;
case Fatal:
fd = open("log/Fat.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
break;
}
if (fd > 0)
{
write(fd, finalbuffer, sizeof(finalbuffer));
close(fd);
}
}
}
};
log lg;
daemon.hpp:
#include<unistd.h>
#include<string>
#include<cstdlib>
#include<signal.h>
#include<fcntl.h>
#include"log.hpp"
extern log lg;
std::string filename = "/dev/null";
std::string workingdirectory="/";
class Daemon
{
public:
/**
* 将进程转换为守护进程的函数
* 守护进程是在后台运行的特殊进程,通常在系统启动时自动启动,
* 并且在系统关闭时才终止。它们没有控制终端,因此不会接收用户输入。
*/
void daemon()
{
// 忽略子进程退出信号,防止子进程成为僵尸进程
signal(SIGCHLD,SIG_IGN);
// 忽略管道破裂信号,防止在向已关闭的管道写入时导致进程终止
signal(SIGPIPE,SIG_IGN);
// 忽略挂起信号,防止终端关闭时发送的信号导致进程终止
signal(SIGHUP,SIG_IGN);
// 第一次fork创建子进程,父进程退出
pid_t id=fork();
if(id<0)
{
// 记录fork失败日志
lg.logmessage(Fatal,"fork fail");
exit(1);
}else if(id>0)
{
// 父进程退出,使得子进程成为孤儿进程,被init进程接管
exit(0);
}
// 创建新的会话,使进程成为会话组长,脱离终端控制
int n=setsid();
if(n<0)
{
// 记录setsid失败日志
lg.logmessage(Fatal,"setsid fail");
exit(1);
}
// 打开/dev/null文件,用于重定向标准输入输出
int fd = open(filename.c_str(), O_RDWR);
if (fd < 0)
{
// 记录打开/dev/null失败日志
lg.logmessage(Fatal, "open dev/null fail");
exit(1);
}
// 将标准输入、标准输出和标准错误重定向到/dev/null
dup2(fd,STDIN_FILENO);
dup2(fd,STDOUT_FILENO);
dup2(fd,STDERR_FILENO);
// 更改工作目录到指定目录,通常为根目录或专用目录
if(chdir((workingdirectory).c_str())<0)
{
// 记录更改目录失败日志
lg.logmessage(Fatal,"chdir fail");
close(fd);
exit(1);
}
// 记录守护进程创建成功日志
lg.logmessage(info,"demonize successfully");
}
};
ServerCal.cpp:
#include"Tcpserver.h"
#include"ServerCal.hpp"
extern log lg;
void usage(const char* programname)
{
lg.logmessage(Fatal, "usage error:%s <port>", programname);
}
/**
* 程序主入口函数
* 使用说明:
* 程序需要接收一个命令行参数作为服务器端口号
* 例如:./program 8080
*/
int main(int argc, char* argv[])
{
// 检查命令行参数个数是否正确
// 程序需要且仅需要一个端口号参数
if (argc != 2)
{
// 参数个数不正确时,打印使用说明
usage(argv[0]);
// 退出程序,返回使用错误状态码
exit(Usage_Error);
}
// 将命令行参数中的端口号字符串转换为16位无符号整数
uint16_t port = std::stoi(argv[1]);
// 创建计算器服务实例
Calculator cal;
// 创建TCP服务器实例
// 参数说明:
// - _default: 默认配置("0.0.0.0")
// - port: 服务器监听端口号
// - std::bind: 绑定Calculator的Calculate方法作为请求处理函数
// - &Calculator::Calculate: 要绑定的成员函数
// - &cal: 绑定的对象实例
// - std::placeholders::_1: 占位符,表示接收客户端请求的参数
Tcpserver server(_default, port, std::bind(&Calculator::Calculate, &cal, std::placeholders::_1));
// 初始化服务器
server.init();
// 启动服务器,开始监听和处理客户端请求
server.start();
// 程序正常退出
return 0;
}
客户端
了解服务端实现后,客户端的实现则相对简单。在 Linux 中,进程通常通过命令行启动,并可通过命令行参数传递配置信息。因此,我们可以将服务端的 IP 地址和端口号以命令行参数的形式传递给客户端进程。
Bash 会获取用户从键盘输入的字符串(即指令),并以空格为分隔符,将其拆分为命令名和参数两部分。在本场景中,参数总数为 3。客户端进程首先检查参数个数是否为 3,若不是,则打印错误信息并退出;若参数数量正确,则解析第二个参数(即端口号),并将其转换为整型。
接下来,客户端初始化服务端对应的地址结构体,随后创建并绑定套接字。需要注意的是,由于服务端为客户端提供的是短连接 TCP 服务,一旦处理完请求便会主动断开连接。因此,客户端的逻辑是持续向服务端建立连接、发送请求并获取响应。一个套接字仅能与一个建立连接的对方套接字通信,若对方断开连接,本端也应主动关闭该套接字,并重新创建套接字进行下一次连接,而非复用之前的套接字。因为当双方完成四次挥手后,套接字会进入 CLOSED状态,该状态是单向的,无法回到初始状态。因此,创建套接字和绑定的操作应位于循环内部。
套接字创建并绑定成功后,便进入正式通信环节。在此基础上,我们将额外引入重连机制。
需要注意的是,客户端可能无法成功与服务端建立连接,最直接的表现是connect 接口调用失败。此前处理方式是直接让客户端进程退出,这一设计并不合理。我们首先需要分析 connect 调用失败的常见原因。
connect 接口的底层行为对应 TCP 的三次握手。客户端主动发起握手,而服务端的监听套接字对应的 sock
结构体会维护两个队列:半连接队列(存放正在三次握手的连接)和全连接队列(存放已完成三次握手的连接)。 accept系统调用会从全连接队列中取出并移除连接节点。当监听套接字收到客户端的连接请求时,会创建一个节点放入半连接队列。
半连接队列有大小限制。若客户端发送 SYN 报文到达服务端时,半连接队列已满,服务端可能会直接忽略该 SYN 报文。客户端在发送 SYN 后会启动定时器,超时后会重传 SYN,超过一定次数后 connect 会返回 -1 表示连接失败。
此外,若监听套接字设置了相应选项,在半连接队列已满时,服务端也可能向客户端发送 RST 复位报文,表示拒绝连接。客户端收到 RST 后, connect也会立即失败。
connect调用失败的另一个原因是服务端未在指定端口监听。内核在网络栈中为 TCP 连接维护两个哈希表,分别对应监听套接字和已连接套接字。监听套接字哈希表的键为二元组(IP, 端口)。若查询该表无对应键,则说明无监听套接字,内核会向客户端发送 RST 报文,导致 connect 失败。
客户端connect() → 发SYN包
↓
服务器收到SYN,内核查询"监听套接字哈希表"
↓
┌─────────────────────┐
│ 该端口有监听套接字吗?│
└─────────────────────┘
↓
是 否
↓ ↓
正常处理 ↓
↓
[发送RST]
↓
客户端connect()立即失败
(ECONNREFUSED)
第三种常见情况与信号中断有关。 connect在发出 SYN 后会进入阻塞状态,等待服务端的 ACK。此时进程状态变为“可中断睡眠”,若在此期间进程收到信号(如 Ctrl+C 发出的 SIGINT),它会被唤醒。 connect 恢复执行后会检查唤醒原因,若发现是被信号中断,则返回 -1 并设置 errno 为 EINTR 。
1. 客户端调用connect()
↓
2. 发送SYN,进入阻塞等待
↓
3. 进程状态:RUNNING → INTERRUPTIBLE_SLEEP
↓
4. 收到信号(如Ctrl+C的SIGINT)
↓
5. 内核唤醒进程
↓
6. connect检查唤醒原因:
├─ 正常收到ACK → 成功返回
└─ 被信号中断 → 返回-1,errno=EINTR
connect失败的原因不止以上三种,但这些是最常见的情况。从设计上看,连接失败是一种正常现象,而非错误。因此,合理的应对策略是进行重连,而非直接退出。但重连时,必须清理之前连接失败的套接字。
有读者可能认为,连接失败后套接字状态会变为 CLOSED ,而初始状态也是 CLOSED ,似乎可以复用。但需要注意,一旦套接字上的操作(如 connect )失败,其对应的 sock 结构体中的 sk_err 字段会记录错误码,且缓冲区中可能残留数据,这意味着该套接字已处于“不干净”的状态,与刚创建时不同。因此,建议的做法是关闭出错的套接字,重新创建一个新套接字进行连接。
因此,客户端与服务端的整个通信过程是一个循环,包括创建套接字、建立连接、正式通信。若 connect 失败,则进行重连。可设置最大重连次数,若超过该次数仍无法连接,则客户端放弃并退出;若连接成功,则进入后续通信环节。
我们可以使用一个 do-while 循环,通过连接成功标志和重连次数来控制循环。若连接成功,则将标志置为 false 并退出循环;若失败,则递减重连次数,清理套接字后继续循环。
int count = 10;
while(count--)
{
bool reconnected = true;
int cnt = 10;
int socketfd = -1;
do {
socketfd = socket(AF_INET, SOCK_STREAM, 0);
if (socketfd < 0)
{
lg.logmessage(Fatal, "socket error");
exit(Socket_Error);
}
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t serverlen = sizeof(server);
int connect_result = connect(socketfd, (struct sockaddr*)&server, serverlen);
if (connect_result < 0)
{
lg.logmessage(info, "connect fail");
close(socketfd);
std::cout << "reconnected .... " << cnt << std::endl;
sleep(1);
cnt--;
}
else
{
reconnected = false;
}
} while(cnt && reconnected);
if(cnt == 0)
{
lg.logmessage(info, "client offline");
close(socketfd);
exit(0);
}
// ...
}
连接建立后,进入正式通信阶段。客户端会生成请求:通过随机数生成两个 100 以内的操作数和一个随机运算符(其中包含一个无效字符用于测试),构造 request 结构体,调用序列化函数 serializtion 进行序列化,再调用 Encode 函数封装协议头。若 Encode 调用失败,则清理套接字,进入下一次循环重试。随后调用 write 将数据发送给服务端。
接着,客户端准备输入缓冲区,调用 read 接收服务端的响应。若返回值小于 0,表示调用失败;若等于 0,表示服务端关闭连接。这两种情况都需关闭套接字并重新循环。若返回值大于 0,则分离消息正文,反序列化后还原出服务端的结构化数据(即 response 结构体)。
这里需注意关闭连接的细节。若服务端或客户端主动调用 close ,其底层行为对应 TCP 的四次挥手。主动关闭的一方完成前两次挥手后,即切断了本方向对端的发送通道,但仍可接收对端数据(因为对端尚未关闭反向通道)。然而, close会释放文件描述符(若引用计数为 0),尽管底层的 sock 结构体及其缓冲区可能仍保留,但由于缺乏文件描述符这一桥梁,应用层无法读取缓冲区中的数据。尽管 TCP 连接是全双工的,但 close 默认是双向关闭的。
若一方已调用 close ,另一方仍发送数据,则主动关闭方在收到数据后会发送 RST 报文。对端收到 RST 后,若正在调用 write ,则会被唤醒并收到 SIGPIPE 信号,该信号默认行为是终止进程。这对客户端来说是不合理的——服务端异常不应导致客户端被终止。因此,客户端应忽略 SIGPIPE 信号。
更优雅的做法是使用 shutdown 接口,它允许只关闭连接的一个方向,而不立即释放文件描述符,并向对端发送 FIN 报文,这样应用层仍可通过文件描述符进行读写。
shutdown- 头文件:<sys/socket.h>
- 函数声明:int shutdown(int sockfd, int how);
- 返回值:成功时返回 0,失败时返回
-1(并设置errno)
how 参数取值:
/* 来自 <sys/socket.h> */
#define SHUT_RD 0 /* 关闭读取端 */
#define SHUT_WR 1 /* 关闭写入端 */
#define SHUT_RDWR 2 /* 关闭读写两端 */
客户端源码
client.cpp:
#include<iostream>
#include<unistd.h>
#include<sys/socket.h>
#include<sys/types.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string>
#include<signal.h>
#include<cstring>
#include<cstdlib>
#include"protocol.hpp"
#include"log.hpp"
#define BUFFER_SIZE 1024
extern log lg;
enum
{
Usage_Error=1,
Socket_Error,
Connect_Error,
Write_Error,
Read_Error,
};
void usage(const char* programname)
{
std::cout << "usage : " << programname << " <ip> <port>" << std::endl;
}
/**
* 客户端程序主入口函数
* 使用说明:
* 程序需要接收两个命令行参数:服务器IP地址和端口号
* 例如:./client 127.0.0.1 8080
* 程序会进行10次测试,每次测试会随机生成计算请求发送给服务器
*/
int main(int argc, char* argv[])
{
// 检查命令行参数个数是否正确
if (argc != 3)
{
// 参数不正确时,打印使用说明
usage(argv[0]);
exit(Usage_Error);
}
// 获取服务器IP地址和端口号
std::string ip = argv[1];
uint16_t port = std::stoi(argv[2]);
// 忽略SIGPIPE信号,防止服务器意外断开时客户端崩溃
signal(SIGPIPE, SIG_IGN);
// 初始化随机数生成器
srand(time(NULL));
// 设置测试次数为10次
int count = 10;
while (count--)
{
bool reconnected = true;
int cnt = 10; // 重连尝试次数
int socketfd = -1;
// 尝试连接服务器,最多重试10次
do {
// 创建TCP套接字
socketfd = socket(AF_INET, SOCK_STREAM, 0);
if (socketfd < 0)
{
lg.logmessage(Fatal, "socket error");
exit(Socket_Error);
}
// 设置服务器地址结构
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(port);
server.sin_addr.s_addr = inet_addr(ip.c_str());
socklen_t serverlen = sizeof(server);
// 尝试连接服务器
int connect_result = connect(socketfd, (struct sockaddr*)&server, serverlen);
if (connect_result < 0)
{
lg.logmessage(info, "connect fail");
close(socketfd);
std::cout << "reconnected .... " << cnt << std::endl;
sleep(1); // 等待1秒后重试
cnt--;
}
else
{
reconnected = false;
}
} while (cnt && reconnected);
// 如果重连10次都失败,退出程序
if (cnt == 0)
{
lg.logmessage(info, "client offline");
close(socketfd);
exit(0);
}
std::cout << "test " << count << " start :" << std::endl;
// 生成随机测试数据
int _data1 = rand() % 100; // 第一个操作数
int _data2 = rand() % 100; // 第二个操作数
int op_num = rand() % 8; // 随机操作符编号
char _op; // 操作符
// 根据随机数选择操作符
switch (op_num)
{
case 0: _op = '+'; break;
case 1: _op = '-'; break;
case 2: _op = '*'; break;
case 3: _op = '/'; break;
case 5: _op = '%'; break;
default: _op = '('; break;
}
std::cout << "data1 :" << _data1 << " data2:" << _data2 << " op:" << _op << std::endl;
// 创建请求对象并序列化
request req(_data1, _data2, _op);
std::string input = req.serialization();
// 对请求数据进行编码
bool en_code = Encode(input);
if (en_code == false)
{
lg.logmessage(warning, "encode fail");
close(socketfd);
continue;
}
// 发送请求到服务器
int write_result = write(socketfd, input.c_str(), input.size());
if (write_result < 0)
{
lg.logmessage(Fatal, "write error");
close(socketfd);
exit(Write_Error);
}
// 接收服务器响应
char buffer[BUFFER_SIZE];
memset(buffer, 0, sizeof(buffer));
int read_result = read(socketfd, buffer, sizeof(buffer));
if (read_result < 0)
{
lg.logmessage(Fatal, "read error");
close(socketfd);
exit(Read_Error);
}
else if (read_result == 0)
{
lg.logmessage(info, "server disconnect");
break;
}
else
{
buffer[read_result] = '\0';
std::string output(buffer, read_result);
// 对响应数据进行解码
bool de_code = Decode(output);
if (de_code == false)
{
lg.logmessage(warning, "decode fail");
close(socketfd);
continue;
}
// 反序列化响应数据
response res;
bool is_des = res.Deserialization(output);
if (is_des == false)
{
lg.logmessage(warning, " Deserialization fail");
continue;
}
// 打印服务器响应结果
std::cout << "server response : result:" << res.result << " coode :" << res.code << std::endl;
}
// 关闭当前连接
close(socketfd);
}
lg.logmessage(info, "client normal offline");
return 0;
}
运行截图:

结语
那么这就是本篇文章的全部内容,带你全面认识以及掌握守护进程以及自定义协议以及序列化和反序列化,并且实现网络计算器,那么下一期博客我会更新http,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!

转载自CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2301_80260194/article/details/156367612



