多进程编程
一 、进程的基本概念
进程是一个具有独立功能的程序对某个数据集在处理机上的执行过程,是操作系统资源分配的基本单位。
操作系统的首要功能就是管理和协调各种计算机系统资源,包括物理的和虚拟的资源。为了提高计算机系统中各种资源的利用效率,操作系统采用了多道程序技术,使多种硬件资源能够并行工作。
二、进程的描述
2.1、进程控制块(Process Control Block, PCB)
进程由3部分组成:PCB、程序段、处理的数据集;
系统为每个进程设置一个PCB,它是标识和描述进程的数据块,是进程存在的唯一标识,反映了进程的动态特征。当创建一个进程时,系统首先创建PCB,然后根据PCB中的信息对进程实施有效的管理和控制。当一个进程完成其功能后,系统则释放PCB,进程也随之消失。
PCB主要包括以下内容:
- 进程标识
- 状态信息 就绪、执行、等待状态;
- 优先级
- CPU现场信息 当进程状态变化时(如一个进程放弃使用处理机),它需要将当时的CPU现场保护到内存中,以便再次占用处理机时恢复正常运行。
- 资源清单
- 队列指针 用于将处于同一状态或者具有家族关系的进程链接成一个队列,在该单元中存入下一进程的PCB首地址。
- 其它,如计时信息、记账信息、通信信息等。
Linux支持两种进程:普通进程和实时进程。
实时进程具有一定程度上的紧迫性,应该有一个短的响应时间。实时进程会一直运行直到退出,除非阻塞才会释放CPU;只能被更高优先级的实时进程抢占CPU;比普通进程的优先级都要高。
Linux中的每个进程都由一个 task_struct 数据结构来表示,它就是通常意义上的进程控制块。Linux为每个新创建的进程动态地分配一个task_struct结构,放在内存中,被内核中的诸多模块访问。系统所能允许的最大进程数是由机器所拥有的物理内存的大小决定的。
task_struct结构体中包含的数据主要有以下信息:
- 进程标识符信息
- 进程调度信息
操作系统利用这些信息来决定系统中哪个进程最迫切需要运行,并采用合适的策略来保证调度的公平性和高效性。这些信息主要包括调度标志、调度策略、进程的类别、进程的优先级、进程状态。这里进程的状态有:可运行状态、可中断的等待状态、不可中断的等待状态、暂停状态和僵死状态。 - 进程间通信信息
- 时间和定时器信息
内核需要记录进程的创建时间以及在其生命周期中消耗的CPU时间。进程耗费CPU时间由两部分组成:一是在用户态(用户模式)下耗费的时间,二是在内核态(内核模式)下耗费的时间。 - 进程链接信息
Linux所有进程都是相互联系的。除了初始化进程init外,其它所有进程都有一个父进程。当创建子进程时,除了进程标识符(PID)等必要的信息外,子进程的task_struct结构中的绝大部分信息都是从父进程中复制过来的。每个进程对应的task_struct结构中都包含有指向其父进程、兄弟进程(具有相同父进程的进程)以及子进程的指针。有了这些指针,能方便进程间通信、协作。 - 文件系统信息
进程经常会访问文件系统资源,打开或者关闭文件,Linux内核要对进程使用文件的情况进行记录。 - 虚拟内存信息
Linux采用按需分页的策略来解决进程的内存需求,当物理内存不足时,Linux内存管理系统需要把内存中的部分页面交换到外存。 - 处理器特定信息
- 其它信息
2.2、进程标识符PID
进程标识符也就是进程的识别码(Process Identification,进程ID,PID)。PID在系统中其实就是一个无符号整形数值,类型是pid_t。
获取当前进程的ID:
#include <iostream>
#include <unistd.h>
using namespace std;
int main(int argc, char *argv[])
{
pid_t pid = getpid();
cout << "pid=" << pid << endl;
return 0;
}
// Linux可以用 echo $$ 查看当前进程PID
在Linux系统的/var/run目录下,一般会看到很多的*.pid文件。PID文件为文本文件,这些文件中内容只有一行,记录了该进程的PID号。这样的目的是防止进程启动多个副本。只有获得相应PID文件写入权限的进程才能正常启动,并把自身的PID定入该文件中。
防止进程重复启动一般有两种方法:一、文件加锁法,进程运行后给.pid文件加一个文件锁,只有获得该锁的进程才有写入权限,以后其它试图获得该锁的进程会自动退出。给文件加锁的函数是fcntl。二、PID读写法,就是先启动的进程往PID文件写入自己的进程PID号,然后其它进程判断该PID文件是否有数据了。
三、进程的创建
3.1、fork
Linux可以通过执行系统调用函数fork来创建新进程。由fork创建的新进程就是子进程。
#include <unistd.h>
pid_t fork(void);
// RETURN VALUE
// On success, the PID of the child process is returned in the parent, and 0 is returned in the child.
// On failure, -1 is returned in the parent, no child process is created, and errno is set appropriately.
该函数被执行一次,但返回两次。两次返回的区别是,如果执行成功,在父进程中函数的返回值是子进程的PID。在子进程中函数的返回值是0。如果失败,则在父进程中返回-1,并且可以通过errno得到错误码。
1)利用fork创建5个子进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("fork begin!\n");
pid_t pid;
int i;
for (i = 0; i < 5; i++)
{
pid = fork();
if (pid > 0)
{
// parent process
}
else if (pid == 0 )
{
// child process
break;
}
else if (pid < 0 )
{
perror("create child process failed!\n");
exit(1);
}
}
if (i < 5)
{
// child process sleep
sleep(i);
printf("I am the %dth child process, pid=%u, ppid=%u\n", i+1, getpid(), getppid());
}
else
{
sleep(5);
printf("I am the parent process, pid=%u, ppid=%u\n", getpid(), getppid());
}
return 0;
}
2)fork创建的子进程与父进程有哪些相同?哪些不同?
相同:
子进程相当于复制了父进程的虚拟地址空间。
bss、data、text、全局变量、栈、堆、环境变量、用户ID、宿主目录(用户home目录)、工作目录、信号处理方式等都相同。
不同点:
1、pid
2、ppid
3、fork返回值
4、进程运行时间
5、闹钟(定时器)
6、未决信号集
7、子进程不继承父进程的锁信息(内存锁、记录锁)
3)父子进程共享哪些信息?
1、mmap映射区
2、文件描述符表
4)什么是读时共享,写时复制?
如果子进程只对变量进行读操作,则操作系统将父子进程中变量的虚拟地址映射到同一物理内存地址中。
只有当子进程需要对变量进行写时,才会将变量的值复制到新的内存物理地址中。
Linix采用了写时复制
的技术,即子进程并不完全复制父进程的内存页,这些内存区域由父、子进程共享,内核将它们的权限改为只读。当有进程试图修改这些区域的时候,内核就为相关部分做一下复制。
3.2、exec函数簇
1)函数声明
用一个新的进程替换当前进程,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。 data,bss,stack,text,heap 等都会被覆盖,但是进程ID不变。 这些函数只有
execve()
是系统调用,其他都是在execve()
函数上根据不同的场景不同封装。
#include <unistd.h>
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *filename, char *const argv[],char *const envp[]);
// l == list
// v == char *const argv[]
// e == environ
// p == $PATH
// ETURN VALUE
// The exec() functions return only if an error has occurred.
// The return value is -1, and errno is set to indicate the error.
调用过程中,传入的参数时或环境变量必须以NULL结尾(包括argv),这相当于一个哨兵,标志可变参数的结束。并且,可执行程序的第一个参数必须是可执行程序的文件名,对应argv[0]
。
2)创建子进程执行ps命令
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
int main()
{
pid_t pid;
// 在父进程中打开文件,增加写权限。由于父子进程共享文件描述符表,所以子进程同样可以访问。
int fd = open("./ps_result.txt", O_CREAT | O_WRONLY | O_TRUNC, 0600);
if (fd < 0)
{
perror("open file fialed.\n");
exit(1);
}
pid = fork();
if (pid == -1)
{
perror("create child process failed.\n");
exit(1);
}
else if (pid == 0)
{
// 创建子进程成功
// 将stdout指向fd
int ret = dup2(fd, STDOUT_FILENO);
if (ret == -1)
{
perror("dup2 failed.\n");
}
// 替换子进程,1. arg[0]必须是可执行程序名 2.可变参数列表以NULL结束。
ret = execlp("ps", "ps", "-ef", NULL);
// 还可以用这些函数
// ret = execl("/bin/ps", "ps", "-ef", NULL);
// ret = execle("/bin/ps", "ps", "-ef", NULL, NULL);
if (ret == -1)
{
perror("execlp fialed.\n");
}
}
else
{
// 阻塞等待子进程结束,回收子进程PCB,关闭文件。
wait(NULL);
close(fd);
}
return 0;
}
3)FAQ
1、问什么exec函数簇没有返回值?
其实exec本身是有返回值的。因为exec函数是进行进程替换,替换前,旧的进程stack中记录了exec的返回地址,替换后,stack也被完全替换了,那么exec的返回值也就被丢弃了。所以,就造成替换成功没有返回值的现象了。
相反,如果替换失败了,返回值存在替换前的stack中,就会将返回值返回了。
2、在命令行输入的命令其实就是一个个进程,那么bash怎么样将这些程序跑起来?
bash先读取命令行输入的参数,进行命令解析。然后fork()创建一个子进程,bash开始wait()等待子进程;子进程开始进程替换然后将命令参数传入,去执行。
3、全局变量具有全局性,那么子进程怎样继承父进程的全局变量呢?
所有的程序都是从main函数开始的,而main函数有几个参数argc、argv、env等:其实就是在子进程函数替换时bash通过main函数的参数把环境变量给了要执行的进程。
3.3、system
system函数通过调用shell程序来执行所传入的命令,效率低。
#include <stdlib.h>
int system(const char *command);
system函数在执行的过程中,进行了3步操作:
- fork一个子进程;
- 在子进程中调用exec函数去执行/bin/sh -c command;
- 在父进程中调用wait等待子进程结束。如果fork失败,system()函数就返回-1。如果exec执行成功,则返回command执行完成后exit或者retrun返回的值。
四、进程的调度
进程调度的主要功能是采用某种算法合理有效地将处理机分配给进程,其调度算法应尽可能提高资源利用率,减少处理机的空闲时间。常见的调度算法有四种:
- 先来先服务算法(FCFS)
- 时间片轮转法(Round Robin – RR)
- 优先级算法
进程调度每次将处理机分配给具有最高优先级的就绪进程。进程优先级可以是静态的,也可以是动态的。静态优先级是在进程创建时根据进程初始状态或者用户要求而确定,在进程运行期间不再改变。linux静态优先级只针对实时进程,它由用户赋给实时进程,范围是1~99,以后调度程序不再改变它。动态优先级是指在进程创建时先确定一个初始优先级,以后在进程运行中随着进程的不断推进,其优先级值也会随着改变。动态优先级只应用于普通进程,实时进程的静态优先级总是高于普通进程的动态优先级,因此只有在处于可运行状态的进程中没有实时进程后,调度程序才开始运行普通进程。 - 多级反馈队列算法
就是上面几种算法的结合。
Linux采用的是基于优先级可抢占式的调度系统,并使用schedule函数来实现进程调度的功能。Linux会在进程状态改变时进行进程调度,进程状态有以下几种:
- 可运行状态
- 可中断的等待状态
- 不可中断的等待状态
- 暂停状态
- 僵死状态
五、进程的分类
Linux进程分为前台进程、后台进程和守护进程3种。
5.1、前台进程
占据前台shell,需要和用户交互。
5.2、后台进程
在shell执行一个程序时,在后面加一个’&’。它不会占据shell,我们可以继续在shell下进行其它操作,但是当shell退出时,后台进程也会随之退出。通常把后台进程称为job。可以通过jobs命令查看当前shell所有后台进程。它会输出job ID 和 PID。如果要终止某个后台进程可以通过 killall jobname 搞定。
六、守护进程
守护进程(Daemon Process,daemon 这个单词读 “低萌~”)是独立于控制终端 并且 周期性地执行某种任务 或 等待处理某些发生的事件 的特殊进程。
6.1、守护进程的概念
Linux中大多数服务器都是守护进程实现的,比如Internet服务器inetd、Web服务器httpd、系统日志进程syslogd、数据库服务进程mysqld、作业规划进程crond、打印进程lpd等。
守护进程脱离终端运行,因为这样能避免被任何终端所产生的信息打断,并且其执行结果也不在任何终端上显示。Linux中第一个系统与用户交互的界面称为终端,每一个终端开始运行的进程都会依附于这个终端,这个终端就称为这些进程的控制终端,当控制终端关闭时,相应的进程都会关闭。因此守护进程都要脱离终端控制,默默地在后台提供服务。
守护进程需要以root权限运行,因为它们要使用特殊的端口(1~1024)或访问某些特殊的资源。一个守护进程的父进程是init进程,因为它们真正的父起程在创建出子进程后就先于子进程退出了,所以守护进程是一个由init继承的孤儿进程
(父进程比子进程先结束)。守护进程的名字通常以d结尾,比如sshd、xinetd、crond等。
6.2、守护进程的特点
- 守护进程都具有超级权限。
- 守护进程的父进程都是init进程。
- 守护进程都不具有控制终端,其
TTY
列的值为?
,TPGID
(tty 进程组ID)为-1
。 - 守护进程都是各自进程组合会话进程的唯一进程。
6.3、查看守护进程
ps axj
, a
表示不仅列出当前用户的进程,也列出其它所有用户的进程。x
表示不仅列出有控制终端的进程,也列出所有无控制终端的进程。j
表示列出与作业控制相关的信息。
6.4、守护进程的分类
守护进程可以分为独立启动守护进程
和超级守护进程
两类。
独立启动守护进程
:进程随系统启动,启动后就常驻内存,会一直占有系统资源。此类守护进程通常保存在/etc/rc.d/init.d
目录下。
超级守护进程
:系统启动时,由一个统一的守护进程xinet
来负责管理一些进程,当响应请求到来时,需要通过xinet的转接才可以唤醒被xinet
管理的进程。这样的做的优点是,最初只有xinet
这一守护进程占有系统资源,其它的内部服务并不一直占有系统资源,只有数据包或其它请求到来时都会被xinet
唤醒。并且还可以通过xinet
对它所管理的进程设置一些访问权限,相当于多了一层管理机制。
每个守护进程都会监听一个端口,httpd监听80端口,sshd监听22端口。这些具体的端口信息可以通过cat /etc/services来查看
。并且,守护进程都会有一个脚本,可以理解为配置文件。守护进程的脚本需要放在指定位置,独立启动守护进程的脚本放在/etc/init.d/
目录下,当然也包括xinet
的shell脚本。超级守护进程按照xinet
中脚本的指示,所管理的守护进程位于/etc/xinetd.config
目录下。
6.5、守护进程的启动方式
守护进程的启动方式有以下几种:
- 在系统启动时由启动脚本启动,这些启动脚本通常放在
/etc/rc.d
目录下。 - 利用
inetd
超级服务器启动,如telnet
等。 - 由
crontab
定时启动。 - 在终端用
nohup
(no hung up)启动。
6.6、编写守护进程的步骤
- 创建子进程,父进程退出
所有工作在子进程中进行形式上脱离了控制终端- 在子进程中创建新会话
setsid()函数
使子进程完全独立出来,脱离控制- 改变当前目录为根目录
chdir()函数
防止占用可卸载的文件系统
也可以换成其它路径- 重设文件权限掩码
umask()函数
防止继承的文件创建屏蔽字拒绝某些权限
增加守护进程灵活性- 关闭文件描述符
继承的打开文件不会用到,浪费系统资源,无法卸载- 开始执行守护进程核心工作
举例:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#define MAXFILE 65535
volatile sig_atomic_t _running = 1;
void sigterm_handler(int arg)
{
_running = 0;
}
// 创建守护进程,在/tmp/daemon.log中不断打印信息。
int main()
{
pid_t pc;
int i, fd, len;
char* const buf = "this is a Daemon\n";
len = strlen(buf);
// 1 fork创建子进程。
pc = fork();
if (pc < 0) {
printf("error fork\n");
exit(1);
} else if (pc > 0) // 2 退出父进程,让子进程成为孤儿进程,交由init接管。
exit(0);
// 3 创建新的会话。
setsid();
// 4 改变当前目录为根目录。
chdir("/");
// 5 重设文件权限掩码。
umask(0);
// 6 关闭文件描述符。
for (i = 0; i < MAXFILE; i++) {
close(i);
}
// 7 守护进程退出处理。
signal(SIGTERM, sigterm_handler);
// 8 守护进程工作内容。
while (_running) {
if ((fd = open("/tmp/daemon.log", O_CREAT | O_WRONLY | O_APPEND, 0600)) < 0) {
perror("open");
exit(1);
}
write(fd, buf, len + 1);
close(fd);
usleep(10 * 1000);
}
}