进程的概述
进程的概念
直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源,所以我们也说,进程是资源分配的最小单位。
进程调度中的三种状态
- 运行:当一个进程在处理机上运行时,则称该进程处于运行状态。处于此状态的进程的数目小于等于处理器的数目,对于单处理机系统,处于运行状态的进程只有一个。在没有其他进程可以执行时(如所有进程都在阻塞状态),通常会自动执行系统的空闲进程。
- 就绪:当一个进程获得了除处理机以外的一切所需资源,一旦得到处理机即可运行,则称此进程处于就绪状态。就绪进程可以按多个优先级来划分队列。例如,当一个进程由于时间片用完而进入就绪状态时,排入低优先级队列;当进程由I/O操作完成而进入就绪状态时,排入高优先级队列。
- 阻塞:也称为等待或睡眠状态,一个进程正在等待某一事件发生(例如请求I/O而等待I/O完成等)而暂时停止运行,这时即使把处理机分配给进程也无法运行,故称该进程处于阻塞状态。
UNIX中的进程控制
创建新进程
1.进程标识符
每个进程都有一个非负整形表示唯一的ID,因此用来作为进程的标识符。unix系统的ID为0的进程通常是调度进程,常常被称为交换进程。ID为1的进程为init进程,在自举结束的时候由内核调用。init进程绝不会终止,虽然它是一个用户进程,但是它以超级用户的特权运行,后面会介绍init对于孤儿进程的领养。
以下方法来获得标识符
pid_t getpid(); //返回调用进程的pid
pid_t getppid(); //返回调用进程的父进程pid
2.创建新进程
创建进程可以通过下面两个函数进行创建
pid_t fork(); //子进程中返回0,父进程中返回子进程pid
pid_t vfork(); //子进程中返回0,父进程中返回子进程pid
两者是有区别的,这就要从创建一个进程的过程中对于父进程相关环境的处理说起了。
两个函数都是调用一次之后会返回两次,对于子进程而言返回值是0,在父进程中又返回的是子进程的pid。这样做的原因也比较好理解:父进程拥有很多子进程,但是没有函数可以获得子进程的pid,所以在分配的时候就必须将子进程的ID进行记录。
两者的不同在于:
- fork之后,子进程是父进程的副本,子进程获得父进程数据空间、堆和栈的拷贝。父子进程之间并不共享这些存储空间部分,但是父子进程共享正文段。但是现在很多采用了写时复制技术,也就是说fork之后父子进程还是先共享这些存储空间部分,只有当子进程试图去修改的时候才会拷贝一份。
- vfork的设计本身就是为了创建进程之后让进程立马调用exec去执行一个新程序,因此vfork之后子进程与父进程还是共享着存储空间,在调用exec之前,子进程还是在父进程的空间中运行。除此之外,vfork和fork的另一个不同之处在于vfork之后,保证子进程先执行
下面的程序揭示了两者之间的区别
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int glob = 6;
char buf[] = "a write to stdout\n";
int main()
{
int var;
pid_t pid;
var = 88;
if(write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1);
exit(-1);
printf("before fork\n");
if((pid = fork()) < 0)
{
printf("fork error\n");
exit(-1);
}
else if(pid == 0)
{
//child
glob++;
var++;
}
else
{
//parent
//父进程休眠2s,让子进程先执行
sleep(2);
}
printf("pid = %d, glob = %d, bar = %d\n", getpid(), glob, var);
exit(0);
}
执行程序的结果如下:
./a.out
a write to stdout
before fork
pid = 430, glob = 7, var = 89
pid = 429, glob = 6, var = 88
./a.out > tmp.txt //将标准输出定位到tmp.txt中,这时为全缓冲
cat tmp.txt
a write to stdout
before fork
pid = 432, glob = 7, var = 89
before fork
pid = 431, glob = 6, var = 88
这里之所以会出现两次before fork是因为标准I/O
当输出定位到终端设备的时候是行缓冲,因此在fork之前,该缓冲碰到了换行符,已经进行了冲洗,所以不会再被子进程所拷贝,但是当输出定位到文件的时候,会变为全缓冲而被子进程拷贝。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int glob = 6;
int main()
{
int var;
pid_t pid;
var = 88;
printf("before fork\n");
if((pid = vfork()) < 0)
{
printf("vfork error\n");
exit(-1);
}
else if(pid == 0)
{
//child
glob++;
var++;
_exit(0);
}
/*
* 父进程可以执行到这里
* /
printf("pid = %d, glob = %d, bar = %d\n", getpid(), glob, var);
exit(0);
}
运行结果如下:
before fork
pid = 29039, glob = 7, var = 89
从上面可以看到,子进程中对于变量的修改在父进程中是可见的。
3.父子进程的文件共享
每一个进程都有一个文件表项,表示打开的文件及状态
创建子进程之后,子进程会复制父进程的所有的打开文件描述符,这样的话就会出现父子进程使用了同一个文件偏移量,
因此父子进程在处理文件的时候就要注意顺序的保持。
进程结束
exit函数
进程有以下5中正常种植方式
main
函数中调用执行return
语句,等效于调用exit- 调用
exit
,其操作包括调用终止处理程序,关闭所有标准I/O流 - 调用
_exit()
或_Exit()
,并不清洗I/O流 - 进程中的最后一个线程在其启动例程中执行返回语句
- 进程中最后一个线程执行
pthread_exit
函数除此之外,进程还有异常退出的情况。
对于任何一种退出情况,我们都希望父进程能知道子进程是如何结束的,对于三个终止函数(
exit、 _exit和_Exit
),它们将退出状态 作为参数返回给父进程,而异常退出的情况,内核产生终止状态
父进程获得子进程的终止状态
父进程利用wait或者waitpid
来获得子进程的终止状态或者退出状态
父进程调用这两个函数可能会出现下面的情况:
- 如果其所有子进程都还在运行,则父进程阻塞,等待一个子进程终止
- 如果一个子进程已经终止,正等待父进程获取其终止状态,则取得子进程的终止状态并立即返回
- 如果没有任何子进程,则返回出错。
pid_t wait(int *status)
pid_t waitpid(pid_t pid, int *status, int options)
//成功则返回进程ID,失败返回-1
这两个函数有所区别:
- 在一个子进程终止之前,wait将其调用者阻塞,而waitpid有一个选项可使调用者不阻塞
- waitpid并不等待在其调用之后的第一个终止进程,它有pid参数,用来指定等待某个进程结束。
下面的程序用来演示不同的exit值
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid;
int status;
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0) /* child */
exit(7);
if(wait(&status) != pid) /* wait for child */
{
printf("wait error");
exit(-1);
}
printf("%d", status);
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0) /* child */
abort(); /* generate SIGABRT */
if(wait(&status) != pid) /* wait for child */
{
printf("wait error");
exit(-1);
}
printf("%d", status);
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0) /* child */
status /= 0; /* divide by 0 generate SIGFPE */
if(wait(&status) != pid) /* wait for child */
{
printf("wait error");
exit(-1);
}
printf("%d", status);
exit(0);
}
僵尸进程和孤儿进程
僵尸进程和孤儿进程的概念
我们知道在unix/linux中,正常情况下,子进程是通过父进程创建的,子进程在创建新的进程。子进程的结束和父进程的运行是一个异步过程,即父进程永远无法预测子进程 到底什么时候结束。 当一个 进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
僵尸进程和孤儿进程的危害
unix提供了一种机制可以保证只要父进程想知道子进程结束时的状态信息, 就可以得到。这种机制就是: 在每个进程退出的时候,内核释放该进程所有的资源,包括打开的文件,占用的内存等。 但是仍然为其保留一定的信息(包括进程号the process ID,退出状态the termination status of the process,运行时间the amount of CPU time taken by the process等)。直到父进程通过wait / waitpid
来取时才释放。 但这样就导致了问题,如果进程不调用wait / waitpid
的话, 那么保留的那段信息就不会释放,其进程号就会一直被占用,但是系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程. 此即为僵尸进程的危害,应当避免。
孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。每当出现一个孤儿进程的时候,内核就把孤 儿进程的父进程设置为init,而init进程会循环地wait()
它的已经退出的子进程。这样,当一个孤儿进程凄凉地结束了其生命周期的时候,init进程就会代表党和政府出面处理它的一切善后工作。因此孤儿进程并不会有什么危害。
任何一个子进程(init除外)在exit()
之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。这是每个子进程在结束时都要经过的阶段。如果子进程在exit()
之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时 处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。 如果父进程在子进程结束之前退出,则子进程将由init接管。init将会以父进程的身份对僵尸状态的子进程进行处理。
僵尸进程危害场景:
例如有个进程,它定期的产 生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程 退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用ps命令查看的话,就会看到很多状态为Z的进程。 严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大 量僵死进程的那个元凶枪毙掉(也就是通过kill发送SIGTERM或者SIGKILL信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被init进程接管,init进程会wait()
这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程 就能瞑目而去了。
僵尸进程的避免
通过fork两次来避免僵尸进程的产生
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
int main()
{
pid_t pid;
//创建第一个子进程
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//第一个子进程
else if (pid == 0)
{
//子进程再创建子进程
printf("I am the first child process.pid:%d\tppid:%d\n",getpid(),getppid());
pid = fork();
if (pid < 0)
{
perror("fork error:");
exit(1);
}
//第一个子进程退出
else if (pid >0)
{
printf("first procee is exited.\n");
exit(0);
}
//第二个子进程
//睡眠3s保证第一个子进程退出,这样第二个子进程的父亲就是init进程里
sleep(3);
printf("I am the second child process.pid: %d\tppid:%d\n",getpid(),getppid());
exit(0);
}
//父进程处理第一个子进程退出
if (waitpid(pid, NULL, 0) != pid)
{
perror("waitepid error:");
exit(1);
}
exit(0);
return 0;
}
子进程执行程序
fork函数执行之后,子进程往往需要调用一种exec函数执行另外的程序。
其中execve()
是内核实现的函数,其他函数都是通过调用这个函数实现的。
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */); (char *)0 为可选参数
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0 , char *const envp[] */); (char *)0和char *const envp[]为可选参数
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */);
int execvp(const char *filename, char *const argv[]);
filename中包含 / 的话就是路径名,不然在就PATH环境变量中查找函数中所指定的文件。
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
char *env_init[] = {"USER=unknown", "PATH=/tmp", NULL};
int main()
{
pid_t pid;
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0)
{
if(execle("/home/sar/bin/echoall", "echoall", "myarg1", "myarg2", (char *)0, env_init) < 0)
{
printf("execle error");
exit(-1);
}
}
if(waitpid(pid, NULL, 0) < 0)
{
printf("waitpid error");
exit(-1);
}
if((pid = fork()) < 0)
{
printf("fork error");
exit(-1);
}
else if(pid == 0)
{
if(execlp("echoall", "echoall", "only 1 arg", (char *)0) < 0)
{
printf("execlp error");
exit(-1);
}
}
exit(0);
}
Linux内核中的进程管理
进程描述符——task_struct
内核将进程的列表放在任务队列(双向循环列表)中,列表中元素类型为task_struct
(进程描述符),下面是其中的片段
struct task_struct {
volatile long state;
void *stack;
unsigned int flags;
int prio, static_prio;
struct list_head tasks;
struct mm_struct *mm, *active_mm;
pid_t pid;
pid_t tgid;
struct task_struct *real_parent;
char comm[TASK_COMM_LEN];
struct files_struct *files;
...
};
在结构体中可以看到几个预料之中的项,比如执行的状态、堆栈、一组标志、父进程、执行的线程(可以有很多)以及开放文件。
state
变量是一些表明任务状态的比特位。最常见的状态有:TASK_RUNNING
表示进程正在运行,或是排在运行队列中正要运行;TASK_INTERRUPTIBLE
表示进程正在休眠、TASK_UNINTERRUPTIBLE
表示进程正在休眠但不能叫醒;TASK_STOPPED
表示进程停止等等。
flags
定义了很多指示符,表明进程是否正在被创建(PF_STARTING
)或退出(PF_EXITING
),或是进程当前是否在分配内存(PF_MEMALLOC
)。
每个进程都会被赋予优先级(static_prio
),但进程的实际优先级是基于加载以及其他几个因素动态决定的。优先级值越低,实际的优先级越高。
tasks
字段提供了链接列表的能力。它包含一个 prev
指针(指向前一个任务)和一个 next
指针(指向下一个任务)。
进程的地址空间由 mm
和 active_mm
字段表示。mm
代表的是进程的内存描述符,而 active_mm
则是前一个进程的内存描述符(为改进上下文切换时间的一种优化)。
在Linux2.6版本之前,各个进程的task_struct
放在它们内核栈的尾端,这样的好处是可以直接利用栈指针就可以访问task_struct
而避免了利用寄存器存储task_struct
地址。2.6版本以后,在栈底(向下增长的栈)或栈顶(向上增长的栈)(不论怎么样,还是在内核栈的尾端)创建新的结构体thread_info
。而通过栈指针访问thread_info
,再通过task_struct
在其中的偏移找到task_struct
。
Linux进程创建
Linux以及其他Unix系统的进程创建很特别。其他很多操作系统都提供了产生进程的机制,首先在新的地址空间创建进程,读入可执行文件,最后开始执行。但是Unix系统将这些步骤分开为fork()
和exec()
两部分。
写时拷贝(copy-on-write)
正如前面所说,现在内核在fork之后不会完全将父进程的资源赋值给子进程,而是采用写时拷贝的技术,也就是说fork之后父子进程还是先是以只读方式共享父进程的地址空间,只有当子进程试图去修改的时候才会拷贝相应的资源,这样fork的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。
fork的过程
Linux系统通过clone()
系统调用实现fork()
。这个调用可以通过参数来指明父子进程需要共享的资源,这个特征也是后面Linux实现线程的基础。fork()
、vfork()
和__clone()
都是通过各自需要共享所代表的参数调用clone()
,然后由clone()
去调用do_fork()
。
do_fork()
完成创建的大部分工作,该函数又调用copy_process()
,然后让程序开始执行。调用的层次结构如下。
copy_process()
函数主要按下面的步骤进行:
1)调用dup_task_struct()
为新进程分配内核栈、thread_info
结构以及task_struct
,这些值与当前进程的值相同。此时子进程与父进程的描述符是完全相同的。
2)检查并确保新进程创建这个子进程之后,当前用户所用的进程数目没有超出它所分配的资源的限制。
3)子进程开始着手将自己和父进程区分开来,进程描述符内的很多成员都要被清0,或设为初始值,但是还是有很多成员没有修改。
4)子进程的状态state
被修改为TASK_UNINTERRUPTIBLE
(即使有信息唤醒该进程,也不会响应),以保证它不会投入运行。
5)copy_process()
调用copy_flags()
以更新task_struct
的flags
成员。表明是否有超级用户权限的PF_SUPERPRIV
被清0,表明进程还没有被exec()
函数调用的PF_FORKNOEXEC
标志位被设置。
6)调用alloc_pid()
为新进程分配一个有效地pid
。
7)根据clone()
传进来的参数,copy_process()
拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间等。
8)最后copy_process()
收尾并返货一个指向子进程的指针。
Linux进程结束
进程退出
进程结束的时候,内核必须释放它所占用的资源,并将这个消息告知父进程。一般进程的析构是自身引起的。调用exit()
后,大部分的工作由do_exit()
处理。
1)将task_struct
中的flags
设为PF_EXITING
2)调用del_timer_sync()
删除任一内核计数器。根据返回的结果,确保没有定时器在排队,也没有定时器处理程序在运行。
3)如果BSD的记账功能开启,调用acct_update_intrgrals()
来输出记账信息。
4)调用exit_mm()
函数释放进程占用的mm_struct
(进程用户空间结构),如果没有别的进程共享,则彻底释放。
5)调用sem_exit()
函数。如果进程排队等候IPC信号,则里考队列。
6)调用exit_files()
和exit_fs()
以分别递减文件描述符和文件系统数据的引用计数。如果引用计数为0,证明没有进程使用,则彻底释放。
7)将存放在task_struct
的exit_code
成员中的任务退出码设置为由exit()
提供的退出代码,或者去完成任何其他由内核提供的退出动作。退出代码放在这里由父进程进行检索
8)调用exit_notify()
向父进程发送信号,给子进程重新找养父,养父为线程组中的其他线程或者为init进程,并将exit_state
设置为EXIT_ZOMBIE
。
9)do_exit()
调用schedule()
切换到新进程,因为处于EXIT_ZOMBIE
的进程不会被调度,因此do_exit()
不返回。
至此,进程的资源都被释放了,剩下的就剩内核栈,thread_info
和tast_struct
结构,要告知父进程来处理这些结构,不然就成僵尸进程了。
父进程回收退出进程的剩余结构
上面说了,调用了do_exit()
之后,尽管进程已经僵死不能再运行了,但是系统还保留了它的进程描述符。需要报告父进程让父进程来回收。wait()
一族的函数都使调用wait4()
系统调用来实现的。它的准动作是挂起调用它的进程,知道其中一个子进程退出,此时函数会返回该子进程的PID。此外,调用该函数时提供的指针会包含子进程退出时的退出码。
释放进程描述符的时候,release_task()
被调用:
1)从pidhash
上删除该进程,同时从任务列表中删除该进程。
2)释放目前僵尸进程所使用的所有资源,并进行最后的统计和记录。
3)如果这个进程是进程组的最后一个进程,并且领头进程已经四地哦啊,则通知僵死的零头进程的父进程。
4)释放进程内核栈和thread_info
结构所占的页,并释放task_struct
所占的slab高速缓存。
参考
1.《UNIX环境高级编程》
2.《Linux内核设计与实现》