更新时间: 2018-01-11 21:07:33       分类: 系统编程


程序,进程和线程

现在我们再次详细的讨论这三个概念

程序(program)

程序是指编译过的、可执行的二进制代码,保存在储存介质上,不运行

进程(process)

进程是指正在运行的程序。

进程包括了很多资源,拥有自己独立的内存空间。

线程

线程是进程内的活动单元。

包括自己的虚拟储存器,如栈、进程状态如寄存器,以及指令指针。

PID

可以参考之前的基础概念部分。

在C语言中,PID是由数据类型pid_t来表示的。

运行一个进程

创建一个进程,在unix系统中被分为了两个流程。

  1. 把程序载入内存并执行程序映像的操作:exec
  2. 创建一个新进程:fork

exec

最简单的exec系统调用函数:execl()

int execl(const char * path,const chr * arg,...)

execl()调用将会把path所指的路径的映像载入内存,替换当前进程的映像。

参数arg是以第一个参数,参数内容是可变的,但最后必须以NULL结尾。

int ret;

ret = execl("/bin/vi","vi",NULL);

if (ret == -1) {
	perror("execl");
}

上面的代码将会通过/bin/vi替换当前运行的程序

注意这里的第一个参数vi,是unix系统的默认惯例,当创建、执行进程时,shell会把路径中的最后部分放入新进程的第一个参数,这样可以使得进程解析出二进制映像文件的名字。

int ret;

ret = execl("/bin/vi","vi","/home/mark/a.txt",NULL);

if (ret == -1) {
	perror("execl");
}

上面的代码是一个非常有代表性的操作,这相当于你在终端执行以下命令:

vi /home/mark/a.txt

正常情况下其实execl()不会返回,调用成功后会跳转到新的程序入口点。

成功的execl()调用,将改变地址空间和进程映像,还改变了很多进程的其他属性。

不过进程的PID,PPID,优先级等参数将会被保留下来,甚至会保留下所打开的文件描述符(这就意味着它可以访问所有这些原本进程打开的文件)。

失败后将会返回-1,并更新errno。

其他exec系函数

略,使用时查找

fork

通过fork()系统调用,可以创建一个和当前进程映像一模一样的子进程。

pid_t fork(void)

调用成功后,会创建一个新的进程(子进程),这两个进程都会继续运行。

如果调用成功, 父进程中,fork()会返回子进程的pid,在子进程中返回0; 如果失败,返回-1,并更新errno,不会创建子进程。

我们看下面这段代码

#include <unistd.h>
#include <stdio.h>
int main ()
{
    pid_t fpid; //fpid表示fork函数返回的值
    int count=0;

    printf("this is a process\n");

    fpid=fork();

    if (fpid < 0)
        printf("error in fork!");
    else if (fpid == 0) {
        printf("i am the child process, my process id is %d\n",getpid());
        printf("我是爹的儿子\n");
        count++;
    }
    else {
        printf("i am the parent process, my process id is %d\n",getpid());
        printf("我是孩子他爹\n");
        count++;
    }
    printf("统计结果是: %d\n",count);
    return 0;
}

这段代码的运行结果比较神奇,是这样的:

this is a process
i am the parent process, my process id is 21448
我是孩子他爹
统计结果是: 1
i am the child process, my process id is 21449
我是爹的儿子
统计结果是: 1

在执行了fork()之后,这个程序就拥有了两个进程,父进程和子进程分别往下继续执行代码,进入了不同的if分支。

如何理解pid在父子进程中不同?

其实就相当于链表,进程形成了链表,父进程的pid指向了子进程的pid,因为子进程没有子进程,所以pid为0。

写时复制

传统的fork机制是,调用fork时,内核会复制所有的内部数据结构,复制进程的页表项,然后把父进程的地址空间按页复制给子进程(非常耗时)。

现代的fork机制采用了一种惰性算法的优化策略。

为了避免复制时系统开销,就尽可能的减少“复制”操作,当多个进程需要读取他们自己那部分资源的副本时,并不复制多个副本出来,而是为每个进程设定一个文件指针,让它们读取同一个实际文件。

显然这样的方式会在写入时产生冲突(类似并发),于是当某个进程想要修改自己的那个副本时,再去复制该资源,(只有写入时才复制,所以叫写时复制)这样就减少了复制的频率。

联合实例

在程序中创建一个子进程,打开另一个应用。

pid_t pid;

pid = fork();

if (pid == -1)
	perror("fork");

//子进程
if (!pid) {
	const char * args[] = {"windlass",NULL};
	
	int ret;
	
	// 参数以数组方式传入
	ret = execv("/bin/windlass",args);
	
	if (ret == -1) {
		perror("execv");
		exit(EXIT_FAILURE);
	}
}

上面的程序创建了一个子进程,并且使子进程运行了/bin/windlas程序。

终止进程

exit()

void exit (int status)

该函数用于终止当前的进程,参数status只用于标识进程的退出状态,这个值将会被传送给当前进程的父进程用于判断。

还有一些其他的终止调用函数,在此不赘述。

等待子进程终止

如何通知父进程子进程终止?可以通过信号机制来实现这一点。但是在很多情况下,父进程需要知道有关子进程的更详细的信息(比如返回值),这时候简单的信号通知就显得无能为力了。

如果终止时,子进程已经完全被销毁,父进程就无法获取关于子进程的任何信息。

于是unix最初做了这样的设计,如果一个子进程在父进程之前结束,内核就把这个子进程设定成一种特殊的运行状态,这种状态下的进程被称为僵尸进程,它只保留最小的概要信息,等待父进程获取到了这些信息之后,才会被销毁。

wait()

pid_t wait(int * status);

这个函数可以用于获取已经终止的子进程的信息。

调用成功时,会返回已终止的子进程的pid,出错时返回-1。如果没有子进程终止会导致调用的阻塞直到有一个子进程终止。

waitpid()

pid_t waitpid(pid_t pid,int * status,int options);

waitpid()是一个更为强大的系统调用,支持更细粒度的管控。

一些其他可能会遇到的等待函数

简单的说,wait3等待任意一个子进程的终止,wait4等待一个指定子进程的终止。

创建并等待新进程

很多时候我们会遇到下面这种情景:

你创建了一个新进程,你想等待它调用完之后再继续运行你自己的进程,也就是说,创建一个新进程并立即开始等待它的终止。

一个合适的选择是system():

int system(const char * command);

system()函数将会调用command提供的命令,一般用于运行简单的工具和shell脚本。                                

成功时,返回的是执行command命令所得到的返回状态。

你可以使用fork(),exec(),waitpid()来实现一个system()。

下面给出一个简单的实现:

int my_system(const char * cmd)
{
    int status;
    pid_t pid;

    pid = fork();

    if (pid == -1) {
        return -1;
    }

    else if (pid == 0) {
        const char * argv[4];

        argv[0] = "sh";
        argv[1] = "-c";
        argv[2] = cmd;
        argv[3] = NULL;

        execv("bin/sh",argv);
        // 这传参调用好像有类型转换问题

        exit(-1);

    }//子进程

    //父进程
    if (waitpid(pid,&status,0) == -1)
        return -1;
    else if (WIFEXITED(status))
        return WEXITSTATUS(status);

    return -1;
}

幽灵进程

上面我们谈论到僵尸进程,但是如果父进程没有等待子进程的操作,那么它所有的子进程都将成为幽灵进程,幽灵进程将会一直存在(因为等不到父进程调用,就一直不终止),导致系统运行速度的拖慢。

正常情况下我们不该让这种情况发生,然而如果父进程在子进程结束之前就结束了,或者父进程还没有机会等待其僵尸进程的子进程,就先结束了,这样就不可避免的产生了幽灵进程。

linux内核有一个机制来避免这样的情况发生。

无论何时,只要有进程结束,内核就会遍历它的所有子进程,并且把他们的父进程重新设置为init,而init会周期性的等待所有的子进程,以确保没有长时间存在的幽灵进程。

进程与权限

略,待补充

会话和进程组

进程组

每个进程都属于某个进程组,进程组就是由一个或者多个为了实现作业控制而相互关联的进程组成的。

一个进程组的id是进程组首进程的pid(如果一个进程组只有一个进程,那进程组和进程其实没啥区别)。

进程组的意义在于,信号可以发送给进程组中的所有进程。这样可以实现对多个进程的同时操作。

会话

会话是一个或者多个进程组的集合。

一般来说,会话(session)和shell没有什么本质上的区别。

我们通常使用用户登录一个终端进行一系列操作这样的例子来描述一次会话。

$cat ship-inventory.txt | grep booty|sort

上面就是在某次会话中的一个shell命令,它会产生一个由3个进程组成的进程组。

守护进程(服务)

守护进程(daemon)运行在后台,不与任何控制终端相关联。通常在系统启动时通过init脚本被调用而开始运行。

在linux系统中,守护进程和服务没有什么区别。

对于一个守护进程,有两个基本的要求:其一:必须作为init进程的子进程运行,其二:不与任何控制终端交互。

产生一个守护进程的流程

  1. 调用fork()来创建一个子进程(它即将成为守护进程)
  2. 在该进程的父进程中调用exit(),这保证了父进程的父进程在其子进程结束时会退出,保证了守护进程的父进程不再继续运行,而且守护进程不是首进程。(它继承了父进程的进程组id,而且一定不是leader)
  3. 调用setsid(),给守护进程创建一个新的进程组和新的会话,并作为两者的首进程。这可以保证不存在和守护进程相关联的控制终端。
  4. 调用chdir(),将当前工作目录改为根目录。这是为了避免守护进程运行在原来fork的父进程打开的随机目录下,便于管理。
  5. 关闭所有的文件描述符。
  6. 打开文件描述符0,1,2(stdin,stdout,err),并把它们重定向到/dev/null

daemon()

用于实现上面的操作来产生一个守护进程

int daemon(int nochdir,int noclose);

如果参数nochdir是非0值,就不会将工作目录定向到根目录。 如果参数noclose是非0值,就不会关闭所有打开的文件描述符。

成功时返回0,失败返回-1。

注意调用这个函数生成的函数是父进程的副本(fork),所以最终生成的守护进程的样子就是父进程的样子,一般来说,就是在父进程中写好要运行在后台的功能代码,然后调用daemon()来把这些功能包装成一个守护进程。

这样子看上去好像是把当前执行的进程包装成了一个守护进程,但其实包装的是它派生出的一个副本。


评论

还没有评论