进程
(一)进程的定义
- 进程
“占用内存空间的正在运行的程序。”
进程(Process)是计算机程序在一个数据集合上的一次运行活动。它是操作系统中资源分配的基本单位,同时也是程序执行的一个独立实体。简单来说,进程就是程序的一次执行过程。
- 线程
线程(Thread)是程序执行的最小单位,是进程中的一个执行路径。与进程不同,线程不独立存在,而是在进程的上下文中运行。一个进程可以包含多个线程,这些线程共享进程的资源(如内存、文件句柄等),但它们可以独立执行。
- 进程和线程的区别
-
进程是资源分配的最小单位,而线程是程序执行的最小单位。
-
线程可以看作是进程中的一个执行路径,因此一个进程可以包含多个线程,共享进程的资源。
-
(二)进程 ID
无论进程是如何创建的,所有进程都会从操作系统分配到 ID。此 ID 称为“进程 ID”,其值为大于2的整数,1要分配给操作系统启动后的(用于协助操作系统)首个进程,所以用户进程无法得到 ID 值1。
终端输入命令 ps au
可显示所有进程的详细信息:
(三)调用 fork
函数创建进程
(1)fork()
函数
c 代码解读复制代码#include
// 成功时返回进程ID,失败时返回-1
pid_t fork(void);
fork()
函数创建的子进程是调用该函数的进程(父进程)的副本。子进程继承父进程的几乎所有属性(如程序代码、数据、堆、栈、文件描述符等),但有独立的地址空间。
利用 fork()
函数的以下特点区分程序执行流程:
-
父进程:在父进程中,
fork()
返回子进程的进程 ID (PID)。 -
子进程:在子进程中,
fork()
返回 0。
“父进程”(Parent Process) 是指原进程,即调用 fork
函数的主体,而“子进程”(Child Process)是通过父进程调用 fork
函数复制出的进程:
(2)示例
c 代码解读复制代码#include
#include
int gval = 10;
int main(int argc, char* argv[])
{
pid_t pid;
int lval = 20;
gval++, lval += 5;
pid = fork();
//两个进程都将执行fork函数调用后的语句
if(pid == 0) // 子进程
gval += 2, lval += 2;
else // 父进程
gval -= 2, lval -= 2;
if(pid == 0)
printf("Child Proc: [%d, %d] \n", gval, lval);
else
printf("Parent Proc: [%d, %d] \n", gval, lval);
return 0;
}
僵尸进程
(一)什么是僵尸进程
僵尸进程(Zombie Process),也称为“死亡进程”或“幽灵进程”,是指在 Unix/Linux 系统中,一个已经终止但其父进程尚未回收其退出状态的进程。
(二)僵尸进程的形成过程
-
子进程终止:当一个子进程通过
exit()
系统调用或正常执行完毕而终止时,它的进程控制块(PCB)中的大部分资源(如内存等)都会被释放,但它的进程 ID (PID) 和一些信息(如退出状态)会保留。 -
父进程尚未调用
wait()
系列函数:子进程终止后,内核会向父进程发送SIGCHLD
信号,通知父进程子进程已终止。如果父进程没有调用wait()
或waitpid()
函数来回收(reap)子进程的退出状态信息,则该子进程将保持僵尸状态。 -
僵尸进程:此时,子进程的进程号 (PID) 依然存在,但它已经不再运行,也不占用系统资源(除了进程表中的一项)。这种状态的进程称为僵尸进程。
(三)僵尸进程示例
c 代码解读复制代码#include
#include
int main(int argc, char* argv[])
{
// 调用 fork() 函数创建子进程
pid_t pid = fork();
// 检查 fork() 返回值
if(pid == 0)
puts("Hi, I am a child process"); // 子进程执行的代码
else
{
printf("Child Process ID: %d \n", pid); // 父进程输出子进程的 PID
sleep(30); // 父进程休眠30秒
}
// 根据 pid 判断是父进程还是子进程并输出相应信息
if(pid == 0)
puts("End child process"); // 子进程执行的代码
else
puts("End parent process"); // 父进程执行的代码
return 0;
}
这段代码中,如果子进程在父进程休眠的 30 秒内结束,它会进入僵尸状态,直到父进程结束或调用 wait()
函数来回收子进程的资源。然而,这段代码中没有调用 wait()
,因此在父进程休眠期间会产生一个僵尸进程。
当父进程结束时,所有子进程(包括僵尸进程)的资源会被内核自动清理。
(四)销毁僵尸进程
前面已经提到,父进程可以通过调用 wait()
或 waitpid()
函数回收子进程的资源,即销毁僵尸进程。
(1)wait()
函数
c 代码解读复制代码#include
// 成功时返回终止的子进程ID,失败时返回-1
pid_t wait(int * status);
status
参数用于存储子进程的终止状态,可以通过宏来解析状态值:
WIFEXITED(status)
:如果子进程正常终止,则此宏返回一个非零值。WEXITSTATUS(status)
:在WIFEXITED
为真时,返回子进程的退出状态代码。
示例:
c 代码解读复制代码#include
#include
#include
#include
//调用wait函数时,如果没有已中止的子进程,那么程序将阻塞(Blocking)直到有子进程中止,因此需谨慎调用该函数
int main(int argc, char* argv[])
{
int status;
pid_t pid = fork();
if(pid == 0)
return 3;
else
{
printf("Child ID: %d \n", pid);
pid = fork();
if(pid == 0)
exit(7);
else
{
printf("Child ID: %d \n", pid);
wait(&status);
if(WIFEXITED(status)) // 正常中止时返回真
printf("Child send one: %d \n", WEXITSTATUS(status));
wait(&status);
if(WIFEXITED(status)) // 正常中止时返回真
printf("Child send two: %d \n", WEXITSTATUS(status));
sleep(30);
}
}
return 0;
}
调用 wait
函数时,如果没有已中止的子进程,那么程序将阻塞(Blocking)直到有子进程中止,因此需谨慎调用该函数
(2)waitpid()
函数
之前提到 wait()
函数会引起程序阻塞,而 waitpid()
函数可以防止程序阻塞。
c 代码解读复制代码#include
// 成功时返回终止的子进程ID(或0),失败时返回-1
pid_t waitpid(pid_t pid, int *status, int options);
-
pid:
pid > 0
:等待进程 ID 为pid
的特定子进程。pid == 0
:等待与调用进程处于同一进程组中的任何子进程。pid < -1
:等待进程组 ID 为abs(pid)
的任何子进程。pid == -1
:等待任何子进程(与wait
行为相同)。
-
status:与
wait
相同,指向一个整数,用于存储子进程的终止状态。如果不关心子进程的退出状态,可以传入NULL
。 -
options:提供一些额外的选项,通过它可以控制
waitpid
的行为。常用选项包括:WNOHANG
:非阻塞模式。如果没有子进程退出,waitpid
立即返回 0。WUNTRACED
:等待任何已停止的子进程(但未被追踪),即使它没有终止。WCONTINUED
:等待那些在接收到SIGCONT
信号后恢复执行的子进程。
示例:
c 代码解读复制代码#include
#include
#include
int main(int argc, char* argv[])
{
int status;
pid_t pid = fork();
if(pid == 0)
{
sleep(15);
return 24;
}
else
{
// 成功时返回终止的子进程ID(或0),失败时返回-1
while(!waitpid(-1, &status, WNOHANG))
{
sleep(3);
puts("sleep 3sec.");
}
if(WIFEXITED(status))
printf("Child send %d \n", WEXITSTATUS(status));
}
return 0;
}
可以看到第21行共执行了5次,证明了 waitpid()
函数并未阻塞。
信号处理
通过前面的内容,我们已经知道了进程创建及销毁方法,但是还有一个问题没有解决:
子进程究竟何时终止?调用 waitpid
函数后要无休止地等待吗?
父进程往往与子进程一样繁忙,因此不能只调用 waitpid
函数以等待子进程终止。解决方法需要引入信号处理(Signal Handling) 机制。子进程终止的识别主体是操作系统,可以由操作系统告知父进程其子进程终止,此时父进程暂时放下工作,处理子进程终止相关事宜。
(一)信号与 signal 函数
(1)signal()
函数
c 代码解读复制代码#include
void (*signal(int signo, void(*func)(int)))(int);
-
int signo
:表示信号编号,即需要捕捉的信号类型。SIGALRM
:已到通过调用alarm
函数注册的时间。SIGINT
:输入CTRL + C
。SIGCHLD
:子进程终止。
-
void (*func)(int)
:这是一个函数指针,指向用户定义的信号处理函数。该处理函数接受一个int
类型的参数(通常是信号编号)并返回void
。 -
void (*)(int)
:signal
函数的返回值是一个指向函数的指针,该函数接受一个int
类型的参数并返回void
。这个返回的函数指针通常是之前为该信号设置的旧的信号处理程序。
下面是 signal
函数的一种更简洁的定义方式:
c 代码解读复制代码#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
-
typedef void (*sighandler_t)(int);
:- 这行代码使用
typedef
定义了一个新的类型别名sighandler_t
。 sighandler_t
实际上是一个函数指针类型,它指向一个接受int
参数并返回void
的函数。这种函数通常用作信号处理函数。
- 这行代码使用
-
sighandler_t signal(int signum, sighandler_t handler);
:-
这是
signal
函数的原型。 -
signal
函数接受两个参数:int signum
:信号编号。sighandler_t handler
:信号处理函数的函数指针。
-
signal
函数的返回值类型是sighandler_t
,即一个函数指针,指向之前为该信号设置的处理函数。
-
① alarm
函数
c 代码解读复制代码#include
// 返回0或以秒为单位的距SIGALRM信号发生的所剩时间
unsigned int alarm(unsigned int seconds);
unsigned int seconds
:指定在多少秒后发送SIGALRM
信号。如果seconds
为 0,任何先前设置的闹钟都会被取消(即不会发送SIGALRM
信号)。
② signal 示例
c 代码解读复制代码#include
#include
#include
void timeout(int sig)
{
if(sig == SIGALRM) // SIGALRM:已到通过调用alarm函数注册的时间
puts("Time out!");
alarm(2); // alarm函数在给定时间后产生signal
}
void keycontrol(int sig)
{
if(sig == SIGINT) // SIGINT:输入CTRL+C
puts("CTRL+C pressed");
}
int main(int argc, char* argv[])
{
signal(SIGALRM, timeout);
signal(SIGINT, keycontrol);
alarm(2);
for(int i = 0; i < 3; i++)
{
puts("wait...");
// 发生信号时将唤醒由于调用sleep函数而进入阻塞状态的进程
sleep(100);
}
return 0;
}
上述是没有任何输入时的运行结果,当在运行过程中输入 CTRL + C
,可以看到 "CTRL+C pressed" 字符串。
上述程序中,有一点需要说明:
“发生信号时将唤醒由于调用 sleep
函数而进入阻塞状态的进程”。
(2) sigaction()
函数
sigaction
函数是用于设置信号处理程序的系统调用。与 signal
函数相比,sigaction
提供了更强大的功能和更细粒度的控制,例如可以指定额外的信号处理行为,并且避免了一些历史遗留的陷阱,因此在现代 Unix/Linux 编程中,通常推荐使用 sigaction
代替 signal
。
c 代码解读复制代码#include
// 成功时返回0,失败时返回-1
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
-
int signum
:信号编号,表示要处理的信号类型(例如SIGINT
,SIGTERM
等)。 -
const struct sigaction *act
:一个指向sigaction
结构体的指针,用于指定新的信号处理程序和相关行为。 -
struct sigaction *oldact
:一个指向sigaction
结构体的指针,用于保存先前的信号处理设置。如果不需要保存,可以传递NULL
或 0。
struct sigaction
结构体
sigaction
函数依赖于 struct sigaction
结构体来描述信号处理的行为。该结构体定义如下:
c 代码解读复制代码struct sigaction {
void (*sa_handler)(int); // 信号处理函数指针
sigset_t sa_mask; // 在处理该信号时需要阻塞的其他信号
int sa_flags; // 控制信号处理行为的标志
};
-
sa_handler
:这是一个指向信号处理函数的指针,当指定信号到达时,调用该函数。这个成员类似于signal
函数中的处理函数。 -
sa_mask
:在信号处理程序执行期间需要阻塞的信号集。使用sigemptyset
、sigfillset
、sigaddset
等函数来设置这个信号集。 -
sa_flags
:控制信号处理行为的标志位。例如:SA_RESTART
:使被信号中断的系统调用自动重新启动。SA_NOCLDSTOP
:如果signum
是SIGCHLD
,则当子进程停止时,不会向父进程发送该信号。SA_SIGINFO
:使用sa_sigaction
代替sa_handler
处理信号。
① sigaction 示例
c 代码解读复制代码#include
#include
#include
void timeout(int sig)
{
if(sig == SIGALRM)
puts("Time out");
alarm(2);
}
int main(int argc, char* argv[])
{
struct sigaction act;
act.sa_handler = timeout;
sigemptyset(&act.sa_mask); // 调用sigemptyset函数将sa_mask成员的所有位初始化为0
act.sa_flags = 0;
// int sigaction(int signo, struct sigaction* act, struct sigaction* oldact)
sigaction(SIGALRM, &act, 0);
alarm(2);
for(int i = 0; i < 3; i++)
{
puts("wait...");
sleep(100);
}
return 0;
}
(二)利用信号处理技术消灭僵尸进程
之前提到子进程终止时将产生 SIGCHLD
信号,可以利用这一点处理僵尸进程
c 代码解读复制代码#include
#include
#include
#include
#include
// 信号处理函数,用于处理 SIGCHLD 信号
void read_childproc(int sig) {
int status;
pid_t id = waitpid(-1, &status, WNOHANG);
if(WIFEXITED(status)) { // 检查子进程是否正常退出
printf("Remove proc id: %d \n", id);
printf("Child send: %d \n", WEXITSTATUS(status)); // 获取子进程的退出状态
}
}
int main(int argc, char* argv[]) {
struct sigaction act;
act.sa_handler = read_childproc; // 设置信号处理函数
sigemptyset(&act.sa_mask); // 清空信号屏蔽集
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0); // 为 SIGCHLD 信号设置处理程序
pid_t pid = fork(); // 创建第一个子进程
if(pid == 0) { // 子进程代码
puts("Hi! I am child process1");
sleep(10); // 子进程休眠10秒
return 12; // 子进程返回状态 12
} else { // 父进程代码
printf("Child proc id: %d \n", pid);
pid = fork(); // 创建第二个子进程
if(pid == 0) { // 第二个子进程代码
puts("Hi, I'm child process2");
sleep(10); // 第二个子进程休眠10秒
exit(24); // 子进程退出,状态为 24
} else { // 父进程代码
printf("Child proc id: %d \n", pid);
for(int i = 0; i < 5; i++) {
puts("wait...");
sleep(5); // 父进程每5秒输出一次 "wait..."
}
}
}
return 0;
}
-
设置信号处理函数:
- 使用
sigaction
函数将SIGCHLD
信号与自定义的信号处理函数read_childproc
关联。当一个子进程终止时,操作系统会发送SIGCHLD
信号给父进程,read_childproc
函数将被调用。
- 使用
-
创建子进程:
- 父进程通过
fork()
创建了两个子进程。每个子进程都休眠 10 秒,然后返回一个退出状态(第一个子进程返回 12,第二个子进程返回 24)。
- 父进程通过
-
处理子进程终止:
- 当子进程终止时,
SIGCHLD
信号会触发read_childproc
函数,该函数使用waitpid
非阻塞地(通过WNOHANG
选项)获取已终止的子进程的状态。 - 如果子进程正常退出 (
WIFEXITED(status)
为真),则打印子进程的 PID 和它的退出状态。
- 当子进程终止时,
-
父进程的等待循环:
- 父进程在主循环中每 5 秒输出一次 "wait...",并且等待子进程终止。由于子进程只休眠 10 秒,所以在主循环运行期间,子进程会在
SIGCHLD
信号处理程序中被处理并清理。
- 父进程在主循环中每 5 秒输出一次 "wait...",并且等待子进程终止。由于子进程只休眠 10 秒,所以在主循环运行期间,子进程会在
基于多任务的并发服务器
(一)基于进程的并发服务器模型
每当有客户端请求服务(连接请求)时,回声服务器端都创建子进程以提供服务,为了完成服务,需要经过如下过程:
- ① 第一阶段:回声服务器端(父进程)通过调用
accept
函数受理连接请求。 - ② 第二阶段:此时获取的套接字文件描述符创建并传递给子进程。
- ③ 第三阶段:子进程利用传递来的文件描述符提供服务。
(二) 实现并发服务器
(1)服务器端(echo_mpserv.c)
c 代码解读复制代码#include
#include
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
void read_childproc(int sig)
{
pid_t pid;
int status;
pid = waitpid(-1, &status, WNOHANG); // waitpid函数防止僵尸进程
printf("removed proc id: %d \n", pid);
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
printf("Usage: %s \n" , argv[0]);
exit(1);
}
struct sigaction act;
act.sa_handler= read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
int state = sigaction(SIGCHLD, &act, 0);
int serv_sock = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_adr, clnt_adr;
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*) &serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
if(listen(serv_sock, 5) == -1)
error_handling("listen() error");
while(1)
{
socklen_t adr_sz = sizeof(clnt_adr);
int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if(clnt_sock == -1)
continue;
else
puts("new client connected...");
pid_t pid = fork();
if(pid == -1)
{
close(clnt_sock);
continue;
}
if(pid == 0)
{
close(serv_sock);
int str_len;
char buf[BUF_SIZE];
while((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);
close(clnt_sock);
puts("client disconnected...");
return 0;
}
else
close(clnt_sock);
}
close(serv_sock);
return 0;
}
-
如果
fork()
返回 0,表示这是子进程,此时子进程关闭服务器套接字serv_sock
,并专注于处理客户端连接。它会不断读取客户端发送的数据,并将数据返回给客户端(即回显)。 -
如果
fork()
返回的是正值,表示这是父进程,它关闭客户端套接字clnt_sock
,并继续等待新的连接请求。 -
在子进程中:
close(serv_sock)
:子进程关闭服务器套接字,因为子进程只负责处理当前客户端连接,不再需要接受新的连接请求。close(clnt_sock)
:子进程在处理完客户端请求后关闭客户端套接字,完成数据传输并断开连接。
-
在父进程中:
close(clnt_sock)
:父进程关闭客户端套接字,因为父进程只负责接受新的连接请求,不需要继续处理当前连接。
(2)客户端(echo_client.c)
c 代码解读复制代码#include
#include
#include
#include
#include
#include
#define BUF_SIZE 1024
void error_handling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
int main(int argc, char* argv[])
{
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
if(argc != 3)
{
printf("Usage : %s \n" , argv[0]);
exit(1);
}
sock = socket(PF_INET, SOCK_STREAM, 0);
if(sock == -1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error");
else
puts("Connected.........");
while(1)
{
fputs("Input message(Q to quit):", stdout);
fgets(message, BUF_SIZE, stdin);
if(!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);
message[str_len] = 0;
printf("Message from server: %s", message);
}
close(sock);
return 0;
}
(3)运行结果
(三)通过 fork
函数复制文件描述符
上述服务器端的父进程将2个套接字(一个是服务器端套接字,另一个是与客户端连接的套接字)文件描述符复制给了子进程。
“只是复制文件描述符吗?是否也复制了套接字呢?”
其实并未复制套接字,父进程和子进程共享底层套接字:
上图1个套接字中存在2个文件描述符,只有2个文件描述符都终止(销毁)后,才能销毁套接字。所以调用 fork
函数后,要将无关的套接字文件描述符关掉:
分割 TCP 的 I/O 程序
(一)分割 I/O 程序的优点
在之前的实现的回声客户端中,数据回声方式如下:
“向服务器端传输数据,并等待服务器端回复。无条件等待,直到接收完服务器端的回声数据后,才能传输下一批数据。”
传输数据后需要等待服务器端返回的数据,因为程序代码中重复调用了 read
和 write
函数。只能这么写的原因之一是,程序在1个进程中运行。
现在可以创建多个进程,因此可以分割数据收发过程,模型如下图所示:
综上所述,分割 I/O 程序的好处有:
① 简化程序实现,父进程和子进程分开实现特定功能。
② 可以提高频繁交换数据的程序性能:
(二)回声客户端的 I/O 程序分割
c 代码解读复制代码#include
#include
#include
#include
#include
#include
#define BUF_SIZE 30
void error_handling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
void read_routine(int sock, char* buf)
{
while(1)
{
int str_len = read(sock, buf, BUF_SIZE);
if(str_len == 0)
return;
buf[str_len] = 0;
printf("Message from server: %s", buf);
}
}
void write_routine(int sock, char* buf)
{
while(1)
{
fgets(buf, BUF_SIZE, stdin);
if(!strcmp(buf, "q\n") || !strcmp(buf, "Q\n"))
{
shutdown(sock, SHUT_WR); // 断开输出流
return;
}
write(sock, buf, strlen(buf));
}
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
printf("Usage: %s \n" , argv[0]);
exit(1);
}
int sock = socket(PF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_adr;
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("connect() error!");
pid_t pid = fork();
char buf[BUF_SIZE];
if(pid == 0)
write_routine(sock, buf);
else
read_routine(sock, buf);
close(sock);
return 0;
}
第36行调用 shutdown
函数向服务器端传递 EOF。当然,执行第37行的 return
语句后,可以调用第66行的 close
函数传递 EOF。但现在已通过第60行的 fork
函数调用复制了文件描述符,此时无法通过1次 close
函数调用传递 EOF,所以需要通过 shutdown
函数调用另外传递。
评论记录:
回复评论: