资讯动态

从零入门计算机网络系列(八):进程组、会话与守护进程——将网络计算器改造为后台服务

发布时间:2026/10/22 17:46:59 来源:尧图企业网站定制
本文从Linux的进程组与会话切入深入梳理组长进程、会话创建以及控制终端之间的关系进一步讲清前后台进程组、作业控制和会话销毁等核心机制。在此基础上引出守护进程分析进程守护化的基本原理、文件描述符重定向与日志管理并结合网络版计算器项目把它改造成后台守护进程帮助读者理解Linux服务程序的基本运行方式。目录一、理解进程组进程如何组织与管理1.1 进程组的基本概念与作用1.2 组长进程与进程组的生命周期1.2.1 组长进程的作用与特点1.2.2 进程组的创建与生命周期二、理解会话从用户登录到Shell启动2.1 会话Session的基本概念2.2 用户登录与会话建立过程2.2.1 登录认证与终端分配2.2.2 建立会话与启动登录Shell2.2.3 子进程对进程组与会话关系的继承三、控制终端与前后台作业控制3.1 先厘清几个核心概念3.1.1 进程组与会话层级与从属关系3.1.2 进程组与Shell作业内核组织与用户操作3.2 前台进程组与后台进程组3.3 Shell作业控制常用命令与快捷键3.3.1 查看与切换作业常用控制命令3.3.2 使用快捷键控制前后台任务3.4 会话的生命周期与终止结合Windows机制进行类比四、守护进程从运行原理到服务程序改造4.1 从服务器运行方式理解守护进程4.2 守护进程的基本原理与创建流程4.3 /dev/null与文件描述符重定向4.4 守护进程实战创建、运行与日志管理4.4.1 使用系统提供的daemon()接口4.4.2 守护进程的运行控制与日志记录4.4.3 实战改造将网络版计算器转换为守护进程1新增Daemon.hpp封装守护化功能2改造Log.hpp完善后台运行时的日志记录3改造TcpServer.cc将服务端改造成守护进程4调整Makefile更新项目编译规则5编译运行与功能验证6改造前后对比理解守护化带来的变化一、理解进程组进程如何组织与管理在 Linux 操作系统里进程从来不是孤零零地存在着。为了方便管理和协同调度系统引入了进程组这个概念。1.1 进程组的基本概念与作用每一个进程除了拥有唯一的进程IDPID之外还属于一个进程组。进程组就是一个或多个进程的集合。它们通常协同工作共同完成某项特定任务。每个进程组都有一个唯一的进程组IDProcess Group IDPGID数据类型同样是pid_t本质上是个正整数。用ps命令就能查看进程及其所属的PGID$ ps -eo pid,pgid,ppid,comm | grep test # 结果如下 PID PGID PPID COMMAND 2830 2830 2259 test参数说明-e输出每一个进程的信息every。-o按自定义格式输出指定的列多个列之间用逗号隔开。再看一个管道操作的例子。在命令行里用管道操作符|连接多个命令时系统会为这些命令创建相应的进程并把它们归为一个进程组共同执行任务$ sleep 1000 | sleep 2000 | sleep 3000 [1] 52335 $ ps -axj | grep sleep PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 48328 52333 52333 48328 pts/2 52340 S 1002 0:00 sleep 1000 48328 52334 52333 48328 pts/2 52340 S 1002 0:00 sleep 2000 48328 52335 52333 48328 pts/2 52340 S 1002 0:00 sleep 3000上例里这三个由管道连接的sleep进程互为兄弟进程。它们的PPID相同而且共同拥有同一个PGID52333。进程正是以进程组的形式协作完成任务的1.2 组长进程与进程组的生命周期每个进程组都有一个组长进程Process Group Leader。这个身份怎么认极其直观组长的PID等于PGID。1.2.1 组长进程的作用与特点组长进程的主要职责是创建新的进程组或者在当前组里创建新进程。比如运行下面这条管道命令[nodelocalhost code]$ ps -o pid,pgid,ppid,comm | cat # 输出结果 PID PGID PPID COMMAND 2806 2806 2805 bash 2880 2880 2806 ps 2881 2880 2806 cat看ps进程PID是2880PGID也是2880两者一致它就是该进程组的组长。而cat进程则是这个组里的成员。就算是一个独立的进程没有走管道它启动时也会自己单独成组自己当自己的组长。1.2.2 进程组的创建与生命周期进程组的生命周期从它被创建的那一刻开始一直持续到组里最后一个进程离开为止。这里有一点必须强调只要进程组里还有一个进程活着这个组就依然存在。至于组长进程是不是已经终止根本不影响。组长退了组不一定散组长没了PGID也未必变。组的命看的是最后一个成员不是那个带头的人。二、理解会话从用户登录到Shell启动搞清楚了进程组再往上看一层就是会话Session。会话和进程组关系密切它是 Linux 系统里管理用户登录和终端交互的核心机制。2.1 会话Session的基本概念会话可以看成是一个或多个进程组的集合。一个会话里可以包含多个不同的进程组。每个会话都有一个唯一的会话IDSession IDSID。[nodelocalhost code]$ proc2 | proc3 [nodelocalhost code]$ proc4 | proc5 | proc6 # 注 表示将进程组放在后台运行上面这两条命令一跑proc2和proc3会凑成一个进程组proc4、proc5、proc6凑成另一个进程组。两个组各过各的但它们都属于当前这个会话。用ps axj命令就能把会话和进程组的关系看得清清楚楚# 用管道和 sleep 组成一个进程组放在后台运行 [nodelocalhost code]$ sleep 100 | sleep 200 | sleep 300 # 查看进程信息 [nodelocalhost code]$ ps -axj | head -1 ps -axj | grep sleep | grep -v grep # 结果如下 PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND 2806 4223 4223 2780 pts/2 4229 S 1000 0:00 sleep 100 2806 4224 4223 2780 pts/2 4229 S 1000 0:00 sleep 200 2806 4225 4223 2780 pts/2 4229 S 1000 0:00 sleep 300从输出里能看出两件事这3个sleep进程的PGID都是4223说明它们同属一个进程组它们的SID又都是2780说明它们也运行在同一个会话里。进程组是“小队”会话是“连队”。小队可以有好几支但连队只有一个编号。2.2 用户登录与会话建立过程用Xshell这类终端工具登录Linux服务器时系统底层会走一串会话创建的流程。2.2.1 登录认证与终端分配用户提交账号密码认证一过Linux系统就会为这次连接创建一个全新的虚拟终端文件比如/dev/pts/XXX。这个终端就是接下来交互的“窗口”。2.2.2 建立会话与启动登录Shell登录成功之后操作系统必须为用户新建一个会话Session。会话内部默认会拉起一个进程组里面跑着该用户的登录Shell通常是bash。系统在这里做了两件事把会话的SID设成bash进程的PID。于是bash同时顶着三个身份一个独立进程、一个进程组组长、一个会话的领头进程。bash默认打开这个虚拟终端把它关联成自己的标准输入0、标准输出1和标准错误2。从此终端就是它的嘴巴和耳朵。2.2.3 子进程对进程组与会话关系的继承此后用户在命令行里敲的各种命令比如./proc——都是bash通过fork() → exec()的方式衍生出来的。这些被创建出来的子进程会直接继承bash打开的标准输入、标准输出和标准错误文件描述符。所以我们在终端里跑程序输出能直接刷在当前终端上键盘输入也能直接喂给程序交互就这么天然地接上了。三、控制终端与前后台作业控制为什么直接启动一个任务终端就没法输入其他命令了把任务丢到后台怎么又能继续敲命令了这背后的原理我们一层层揭开。3.1 先厘清几个核心概念为了避免混淆先把两组关键关系摆清楚。3.1.1 进程组与会话层级与从属关系进程组一定属于某一个会话。一个会话可以包含多个进程组但任何一个进程组都不能脱离会话单独存在。组是连队下的排排不能没有连队。3.1.2 进程组与Shell作业内核组织与用户操作任务和进程组是一枚硬币的两面。从用户和系统的交互视角看用户下达给系统执行的一项工作一条指令或者一个管道组合叫一个任务Job可到了操作系统内核的实现层面系统必须创建一个进程组来承载并完成这项工作。用户看到的是“任务”内核管的是“进程组”。3.2 前台进程组与后台进程组回到开头那个问题。本质上原因就一句话键盘只有一个。服务器终端连着的键盘设备是唯一的。键盘输入的数据必须明确发给某一个具体的进程或进程组否则输入就乱套了。于是在会话内部系统把进程组划分成两类前台进程组和后台进程组。键盘输入只实时发送给当前的前台进程组。一个会话里有且只能有一个前台进程组也就是一个前台任务但后台进程组可以同时存在多个。当我们在终端直接运行命令比如./myproc这个任务会抢占前台。bash进程就得让出前台控制权退到后台。这时候键盘输入全被这个前台任务接收bash根本拿不到输入自然没法接收并执行我们敲的新指令。反过来如果带上把任务放到后台运行比如./myproc bash依然独占前台控制权我们敲的命令它照样能收、能执行。3.3 Shell作业控制常用命令与快捷键Linux 备了一套完整的命令和快捷键前后台任务之间来回切换、灵活控制全靠它们。3.3.1 查看与切换作业常用控制命令jobs查看当前会话里所有的后台任务列表顺带把任务号Job ID也亮出来。谁在后台跑着编号是几一目了然。fg 任务号全称front ground把指定的后台任务拽回前台运行。比如fg 1就是把1号后台任务调回前台重新霸占终端。bg 任务号全称background把一个处于暂停状态Stopped的后台任务恢复成后台运行状态。注意它管的是“暂停转运行”不是“前台转后台”别搞混了。3.3.2 使用快捷键控制前后台任务Ctrl C发送SIGINT信号只能终止当前前台任务。后台任务不占前台终端键盘上的Ctrl C压根碰不到它们。Ctrl Z发送SIGTSTP信号暂停当前的前台进程或进程组。前台任务一被暂停系统会自动把它切到后台同时bash自动切回前台重新拿回终端命令行的控制权。一个组合键暂停加交接一气呵成。3.4 会话的生命周期与终止结合Windows机制进行类比Windows系统里用户登录的本质同样是建立一个新会话。用户输入密码登录Windows系统会为用户加载桌面环境。这个“桌面”本质上就是一个图形界面的外壳Shell地位相当于Linux会话里的bash进程。在这个桌面下打开的所有应用程序、后台服务浏览器、播放器、各种常驻程序都相当于运行在这个会话内部的各个进程或进程组。它们的根都扎在同一个会话上。所有的进程组都依赖当前会话而存在。会话在它们就在会话没了它们也就没了根。当用户点击Windows的“注销”按钮时所谓注销本质上就是系统删除并销毁登录时创建的那个会话。会话一销毁所有依赖它运行的前后台进程组都会被系统一并清理、注销掉。皮之不存毛将焉附。四、守护进程从运行原理到服务程序改造搞清楚了会话与销毁的机制自然就会想到一个问题工业界和企业里那些企业级服务器程序到底是怎么做到稳定运行的4.1 从服务器运行方式理解守护进程我们日常开发测试时写的网络服务器比如server_netcal如果只是直接在终端里跑或者简单用丢到后台它们依然属于当前用户登录时创建的那个会话。这种运行模式藏着一个致命隐患用户一旦关闭终端连接比如关掉Xshell系统就会注销并销毁该会话。会话一没跑在会话内部的服务进程势必受牵连严重的话服务器直接崩溃退出。可真正的服务器程序绝不能受任何用户登录、注销的影响。谁登录系统也好什么时候断开终端也罢服务器都得保持7×24小时不断线地稳定运行。要实现这个目标就得把服务器进程“守护化”让它变成一个守护进程Daemon也常被叫作精灵进程。守护进程本质上也是孤儿进程的一种特例它脱离了原有的控制终端与会话独立运行在系统背景中天塌下来也跟它没关系。至于为什么说守护进程是孤儿进程的一种后面讲原理的时候自然会揭晓。4.2 守护进程的基本原理与创建流程要让一个服务进程彻底摆脱登录与注销的影响核心原理其实就一句话把服务器对应的进程组从原来的会话里剥离出来重新创建一个新会话再把这个进程组塞进那个独立的全新会话中。扛起这件事的核心系统调用叫setsid。翻一下Linux手册基本定义如下#include sys/types.h #include unistd.h pid_t setsid(void);setsid()的作用是创建一个新会话并把调用进程设置为该新会话的领头进程Session Leader同时也是新进程组的组长PGID。不过调用setsid有个关键前提卡得很死调用进程不能是一个进程组的组长not a process group leader。这是POSIX标准的硬性规定。组长进程要是直接调setsid()系统会返回错误直接拒绝。所以实现进程守护化的标准底层流程得这么走父进程 fork 出子进程。父进程是当前进程组的组长可fork出来的子进程拥有独立的 PID绝对不是进程组组长。这一刀切得恰到好处。父进程直接退出exit。父进程一终止子进程就成了孤儿进程被1号进程init或systemd领养过去。子进程调用 setsid()。因为子进程不是组长这一调顺理成章。它独立创建出一个新会话彻底跟原有的终端会话解绑。4.3 /dev/null与文件描述符重定向进程成功独立为守护进程之后它已经不再关联任何控制终端。可程序里可能还残留着往终端输出或者从终端读取的逻辑std::cout、printf、cin这些代码并不会因为进程“脱了壳”就自动消失。我们的服务器不需要接收键盘的信息也不需要往显示器上打印。但处理这些残留逻辑不能简单粗暴地close(0)、close(1)、close(2) 一关了之。为什么因为后续代码里很可能调用了依赖这三个标准描述符的第三方库或系统接口。你把它们关了人家一用就出错轻则逻辑混乱重则直接段错误。为了妥善解决这个问题Linux引入了那个特殊的黑洞设备文件/dev/null$ ll /dev/null crw-rw-rw- 1 root root 1, 3 Apr 13 18:17 /dev/null/dev/null的特性非常独特就两条写进去的东西系统直接丢弃连个响都没有。从里面读结果永远是空EOF什么都读不出来。所以守护进程初始化的时候标准做法是打开/dev/null然后用dup2把标准输入0、标准输出1、标准错误2全部重定向到这个文件上。这样一来既堵住了往终端打印信息的出口又保住了文件描述符系统的合法与健全。程序该写写该读读只是写进黑洞读出来是空。终端那头干干净净仿佛什么都没发生过。4.4 守护进程实战创建、运行与日志管理实际项目开发里守护进程的落地既可以照着上面的原理手动封装也可以直接用Linux系统函数库提供的标准接口。4.4.1 使用系统提供的daemon()接口C标准库封装了一个现成的接口一行就能把当前进程守护化#include unistd.h int daemon(int nochdir, int noclose);两个参数意思很直白nochdir传0守护进程的当前工作目录会被改成根目录/传非0就保持当前工作目录不变。noclose传0标准输入0、标准输出1、标准错误2会自动重定向到/dev/null传非 0原有的标准输入输出描述符就保留着。那为什么常常要把工作目录切成根目录/这主要是为了方便软件的发布和部署。守护进程如果在后台长期跑着工作目录还赖在某个挂载目录里那个目录就卸不掉。切到根目录等于把脚从别人的地盘上挪开系统文件系统该卸载就能顺利卸载。4.4.2 守护进程的运行控制与日志记录守护进程在后台独立跑起来之后它已经没法再往控制台打印任何调试信息了。那它的实时运行状况我们怎么查看答案以文件的方式输出日志。现代软件工程里企业级守护进程可执行程序通常以字母d结尾比如mysqld、sshd一般都会维护一套规范的安装路径目录结构大致如下drwxrwxr-x 2 whb whb 4096 Apr 20 15:28 bin/ # 存放可执行二进制文件 drwxrwxr-x 2 whb whb 4096 Apr 20 15:28 conf/ # 存放配置文件 drwxrwxr-x 2 whb whb 4096 Apr 20 15:28 log/ # 存放运行日志文件守护进程启动之后通过绝对路径找到conf/下的配置文件把参数加载进来运行过程中的关键信息和异常报错则实时追加写入log/目录下的日志文件。终端那头安安静静可日志文件里一切都记着。4.4.3 实战改造将网络版计算器转换为守护进程上一期写的网络计算器代码结构还记得Common.hpp、Log.hpp、InetAddr.hpp、Socket.hpp、Protocol.hpp、TcpServer.hpp、TcpServer.cc、TcpClient.cc、Makefile。这次要动的主要是三块新增守护化函数、把日志从终端输出改成文件输出、改服务端主程序。其余文件原样保留。1新增Daemon.hpp封装守护化功能#pragma once #include unistd.h #include fcntl.h #include sys/types.h #include sys/stat.h namespace DaemonModule { // 将当前进程守护化 inline void Daemonize() { // 1. 第一次 fork父进程退出 pid_t pid fork(); if (pid 0) { exit(0); // 父进程退出子进程变成孤儿 } if (pid 0) { exit(1); } // 2. 子进程创建新会话脱离控制终端 setsid(); // 3. 第二次 fork确保进程不是会话领头进程 // 从而无法再次打开控制终端 pid fork(); if (pid 0) { exit(0); } if (pid 0) { exit(1); } // 4. 重定向标准输入、标准输出、标准错误到 /dev/null int fd open(/dev/null, O_RDWR); if (fd 0) { dup2(fd, 0); dup2(fd, 1); dup2(fd, 2); if (fd 2) { close(fd); } } // 5. 切换工作目录到根目录避免占用可卸载的挂载目录 chdir(/); // 6. 重设文件权限掩码保证守护进程创建的文件权限可控 umask(0); } }2改造Log.hpp完善后台运行时的日志记录原来的Log.hpp是直接往std::cout打的。守护化之后标准输出已经指向/dev/null打什么都看不见。所以日志必须改成写文件。改造后的版本保留原来的LOG(level)宏但内部改为写入文件流并加上了互斥锁保证多进程/多线程下日志不串行。#pragma once #include iostream #include fstream #include string #include mutex #include ctime #include sstream #include cstdio namespace LogModule { const std::string CAGE \n; enum class LEVEL { DEBUG, INFO, WARNING, ERROR, FATAL }; inline std::string LevelToString(LEVEL level) { switch (level) { case LEVEL::DEBUG: return DEBUG; case LEVEL::INFO: return INFO; case LEVEL::WARNING: return WARNING; case LEVEL::ERROR: return ERROR; case LEVEL::FATAL: return FATAL; default: return UNKNOWN; } } inline std::string NowTime() { time_t t time(nullptr); struct tm tmBuf; localtime_r(t, tmBuf); char buf[64]; snprintf(buf, sizeof(buf), %04d-%02d-%02d %02d:%02d:%02d, tmBuf.tm_year 1900, tmBuf.tm_mon 1, tmBuf.tm_mday, tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec); return buf; } class Logger { public: static Logger Instance() { static Logger inst; return inst; } // 设置日志文件路径守护进程启动时调用 void SetLogFile(const std::string path) { std::lock_guardstd::mutex lock(_mtx); if (_out.is_open()) { _out.close(); } _out.open(path, std::ios::app); } void Log(LEVEL level, const std::string msg) { std::lock_guardstd::mutex lock(_mtx); std::string line [ NowTime() ][ LevelToString(level) ] msg CAGE; if (_out.is_open()) { _out line std::flush; } else { std::cout line std::flush; } } private: Logger() {} std::ofstream _out; std::mutex _mtx; }; } #define LOG(level) LogModule::Logger::Instance().Log(level, \ std::string(__FILE__) : std::to_string(__LINE__) )3改造TcpServer.cc将服务端改造成守护进程主程序里先解析参数再调用Daemonize()守护化然后把日志文件设成绝对路径因为chdir(/)之后相对路径就失效了。#include TcpServer.hpp #include Daemon.hpp #include memory #include string using namespace LogModule; using namespace DaemonModule; int main(int argc, char *argv[]) { // 1. 先解析参数守护化之前完成 uint16_t port 8080; if (argc 2) { port static_castuint16_t(std::stoi(argv[1])); } // 2. 守护化脱离终端、脱离会话、重定向标准流 Daemonize(); // 3. 设置日志文件绝对路径守护化后相对路径不可靠 Logger::Instance().SetLogFile(/home/whb/netcal/log/netcal.log); LOG(LEVEL::INFO) 守护进程启动监听端口 port; // 4. 创建并启动服务器 std::unique_ptrServerModule::TcpServer server std::make_uniqueServerModule::TcpServer(port); server-Init(); server-Start(); return 0; }4调整Makefile更新项目编译规则TcpServer.cc现在多依赖了Daemon.hppMakefile里补上即可。.PHONY: all all: tcp_server tcp_client tcp_server: TcpServer.cc TcpServer.hpp Socket.hpp InetAddr.hpp Protocol.hpp Common.hpp Log.hpp Daemon.hpp g -o $ $ -stdc17 -ljsoncpp tcp_client: TcpClient.cc Socket.hpp InetAddr.hpp Protocol.hpp Common.hpp Log.hpp g -o $ $ -stdc17 -ljsoncpp .PHONY: clean clean: rm -f tcp_server tcp_client5编译运行与功能验证先在~/netcal/下建好目录mkdir -p ~/netcal/log编译make启动守护进程./tcp_server 8080命令一敲下去终端立刻返回服务已经在后台跑起来了。因为它已经脱离终端你在当前终端里再也看不到它的输出。查看它是否还活着ps -ef | grep tcp_server会看到tcp_server的父进程是1init或systemd说明它已经被1号进程领养成了孤儿也成了真正的守护进程。查看日志tail -f ~/netcal/log/netcal.log客户端照常连接./tcp_client 127.0.0.1 80806改造前后对比理解守护化带来的变化对比项改造前改造后会话归属属于当前登录会话独立新会话不属于任何登录会话控制终端关联终端无控制终端标准输入输出指向终端指向/dev/null日志输出std::cout打到终端写入~/netcal/log/netcal.log关闭终端服务可能被注销服务不受影响继续运行父进程当前shell1 号进程init/systemd如果这篇文章对你有帮助别忘了点赞、收藏、关注三连支持。你的每一次反馈都是我继续硬核输出的最大动力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑