Socket编程(三)——多进程

考完上学期的期末考试了,这学期课还特别少,最近集中精力学完这本书,开始泄洪式更新。

进程和僵尸进程

温故而知新:进程的概念

一个简单且易于理解的定义:“进程是占用内存空间的正在运行的程序。“进程是操作系统进行资源调度的基本单位,资源包括时间、内存和外设等。在网络编程中,在服务端实现多进程可以降低服务的平均时延,提高客户端的访问体验。在Linux操作系统下,可以通过下面这个函数来复制一个进程副本。

1
2
3
4
#include<unistd.h>

pid_t fork(void);
// 成功时返回进程ID,失败返回-1

复制后的进程与父进程使用同一个内存空间,但是父进程中该函数的返回值是子进程的ID,子进程中该函数的返回值为0,可以利用这个特点来区分父子进程。

僵尸进程

当父进程创建一个子进程,并且子进程运行结束,产生返回值时,子进程会把这个返回值传递给操作系统。操作系统会试图将这个返回值传递给父进程,在此之前,操作系统都不会杀死子进程。处在这种运行完毕但没有被杀死的进程就是僵尸进程。很显然,僵尸进程会像其他进程一样占用资源,且不做任何事,所以应该避免僵尸进程的出现。

知道僵尸进程产生的原因后,就知道如何防止其产生。只需要在父进程中主动要求获得子进程的返回值即可。

方式一:wait函数

1
2
3
4
#include<sys/wait.h>
// 传递的指针用于接收子进程的运行状态
// 运行成功时返回子进程ID,失败时返回-1
pid_t wait(int * statloc);

wait函数会让父进程进入阻塞状态,直到有子进程终止,此函数还要用两个宏函数配合使用:

1
2
WIFEXITED(status); // 子进程正常终止时返回true
WEXITSTATUS(status); // 返回子进程的返回值

方式二:waitpid函数

1
2
3
4
/*
* options: 可以传递常量WNOHANG,使得如果没有子进程终止就继续执行下面的代码,不阻塞。
*/
pid_t waitpid(pid_t pid, int * statloc, int options);

信号量

上面提到的两个方法都是父进程在主动等待子进程,更精明的方法是求助操作系统,向操作系统“注册”一个函数,用于收到信号量时执行。

signal

1
2
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);

声明过于复杂,但是不难理解:

  • 函数名:signal
  • 参数:int signo, void(*func)(int)
  • 返回值:参数为int,返回void型的函数指针

第一个参数用于传递常量,代表不同的事件,第二个参数代表事件发生时应该调用的函数。常量有下:

  • SIGALRM:已到通过alarm函数注册的时间(定时器)
  • SIGINT:输入CTRL+C
  • SIGCHLD:子进程终止

alarm函数声明如下:

1
2
unsigned int alarm(unsigned int seconds);
// 返回以秒为单位的剩余时间

返回值似乎很令人迷惑,实际上如果给这个函数传递的参数为0,就会取消对SIGALRM信号的预约,此时可以通过读取返回值来确定剩余的或者已经过去的时间。

sigaction

实际上现在很少使用signal函数,因为sigaction在不同版本的Unix操作系统中完全相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <signal.h>
/*
* act:传递注册函数的信息 oldact:获取这个参数之前注册的处理函数指针
*/
int sigaction(int signo, const struct sigaction * act, struct sigaction * oldact);

struct sigaction
{
void (*sa_handler)(int); // 信号处理函数的指针
// 以下两个在处理僵尸进程时可以直接初始化为0
sigset_t sa_mask;
int sa_flags;
}

// 使用下面这个函数将sa_mask清零
sigemptyset(&act.sa_mask);

sigaction函数操作较为复杂,但无论是可移植性还是灵活性都比signal函数强一些。

多进程服务端

多进程服务端的框架书上介绍得很详细,这里只记录一下其框架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
serv_sock = socket(...);
bind(...);
listen(...);
clnt_sock = accept(...);
pid = fork();
if(pid==0) // child process
{
close(serv_sock);
read_and_write();
close(clnt_sock)
}
else{ //father process
close(clnt_sock);
}

该段伪代码使用fork出的子进程来负责与客户端进行通信,服务端只负责接受客户端的连接请求。需要注意的是在子进程中需要事先关闭不再使用的文件描述符(实际上就是套接字),因为子进程中会复制父进程的文件描述符,而套接字只有当其所有文件描述符都关闭时才会被关闭。所以,如果子进程不事先关闭服务端套接字,父进程就有可能无法关闭套接字。

对于父进程中关闭客户端文件描述符的操作,也是因为此。

套接字和文件描述符的关系

分割客户端的IO进程

对于客户端,使用多进程也可以提高程序执行的效率。书中例子将客户端的发送数据部分放在父进程中,读取回声放在子进程中,这样可以使得客户端的输入和输出在时间上无关,即下一轮的输出无需等待上一轮的输入到达。

进程间通信

以上提到的例子中其实已经设计到一部分的进程通信,但通信的方法是信号量,只能起到相互通知的作用。如果希望在进程之间读取较大规模的数据,需要用到一些其他技巧,例如管道。其实这个部分应该属于操作系统范畴,但是鉴于我寒假读了《操作系统导论》这本书却没有写读书笔记,这里再啰嗦一下罢。

1
2
3
4
5
/*
* 成功时返回0,失败返回-1
* filedes[0]用于父进程读取数据,filedes[1]用于子进程写入数据
*/
int pipe(int filedes[2]);

filedes这个数组我们可以像文件描述符一样使用,调用像writeread这样的IO函数来进行数据传输。根据pipe函数的描述我们可以知道,一个管道只能实现子进程到父进程的单向通信。

如果使用一个管道实现双向通信,可能会引发一些问题,因为“向管道传递数据时,先读的进程会把数据取走”,规划父子进程间数据的读取数据这件事十分繁琐。

总结

多进程的服务端和客户端可以在一定程度上优化程序的执行,但也会带来一些问题,例如占用内存较大等等。所以在实现大型服务端时,通常不会采用这种方式,本部分的学习可以权当是对操作系统知识点的一些回顾。


Socket编程(三)——多进程
http://zhouhf.top/2023/03/13/Socket编程(三)——多进程/
作者
周洪锋
发布于
2023年3月13日
许可协议