Linux多进程编程
1.使用计算机或其它设备时,哪些现象或操作给你的感觉是同时进行或同时发生?
多任务(multitasking)
允许多个程序同时运行。
多用户(multiuser)
允许多个用户同时访问系统,每个用户都可以运行程序。
2.进程基本概念
2.1什么是程序? //文件定义: 对系统资源进行访问的一个通用接口。
程序就是一个文件,该文件包含了一系列信息,这些信息描述了如何在运行时构造一个进程。
程序(文件)中包含哪些信息?
描述了可执行文件的格式(如ELF, Executable and Linking Format),使得内核可以知道文件的剩余信息是什么含义。
表明程序从哪一条指令(instruction)开始执行。
初始化变量的一些值以及字符串常量等。
描述了程序中函数、变量的名字及位置。
程序运行时所需要的共享库等信息。
2.2Linux下可执行程序的分类
可执行目标文件
经链接器链接后可直接执行的文件称为可执行目标文件。内核一般支持几种特定格式的可执行文件。ELF格式是Linux系统中普遍使用的一种标准的可执行文件格式。
可执行脚本
可执行脚本是一个特殊的文本文件,它能够指示内核启动一个解释器去执行后续的内容。这个解释器必须是可执行目标文件。一般情况下,脚本的解释器是Shell,但内核也会查看脚本文件第一行,如果前两个字符是#!,它就会将第一行的剩余部分解析为启动解释器的命令。例如,一个Shell脚本的第一行通常如下:
#!/bin/sh
这样内核将会启动/bin/sh作为脚本的解释器。
2.3进程定义
什么是进程?
进程是一个程序正在执行的实例。每个这样的实例都有自己的地址空间和执行状态。
内核怎么区分不同的进程?
进程有一个PID(Process ID,进程标识),用以区分各个不同的进程。内核记录进程的PID与状态,并根据这些信息来分配系统资源(如内存等)。
当内核产生一个新的PID,生成对应的用于管理的数据结构,并为运行程序代码分配了必要的资源,一个新的进程就产生了。
2.4 进程的内存布局
分配给每个进程的内存(memory)由一系列段(segments)组成:
text segment
包含机器语言指令。通常是只读的、共享的。
initialized data segment
包含初始化的全局变量和静态(static)变量。
uninitialized data segment (bss segment)
包含未初始化的全局变量和静态变量。程序运行之前,系统会把这些变量初始化为0。
包含堆栈帧(stack frames)。一个堆栈帧被分配给当前的被调函数(called function)。一帧保存的内容有:局部变量、参数(arguments)、返回值。
heap
程序运行时,为变量动态分配内存。
注:可以用size命令显示一个二进制可执行文件的text, initialized data, and uninitialized data (bss) segments。
2.5进程状态
执行状态
进程正在占用CPU。
就绪状态
进程已具备一切条件,等待分配CPU。
等待状态
进程不能使用CPU,若等待的事件发生则可将其唤醒。
2.6获得PID
每个进程都有一个ID(ID是一个正整数),唯一标识了系统中的这个进程。
获得调用进程(the calling process)的ID:
#include <unistd.h>
pid_t getpid(void);
每个进程都有一个创建它的父进程(Parent Process),可以通过getppid()获得父进程的ID:
#include <unistd.h>
pid_t getppid(void);
2.7进程的生命周期
创建
每个进程都由其父进程创建。父进程可以创建子进程,子进程又可以创建子进程的子进程。
运行
多个进程可以同时存在,进程之间可以进行通信。
终止
结束一个进程的运行。
2.8进程的树状关系
每个进程有其父进程,父进程又有其父进程...那原始的进程是什么?
init进程!所有进程的祖先(ancestor)是init进程,其PID为1。可以用pstree命令查看树状关系。
当父进程在子进程之前终止会发生什么?
当一个子进程(child process)变为孤儿时(由于其父进程终止),此时该子进程会被init进程领养(adopted)。
2.9进程的进一步了解
现代操作系统可以同时执行多个进程。事实上,对于只有一个CPU的系统来讲,在一个给定时刻只能有一个进程在执行。
内核控制CPU在很短的时间间隔内不断地在各个进程间切换,轮流执行。因为这个间隔很短,所以用户感觉到计算机在同时做几件事情,于是就有了“并发”执行的概念。进程管理是操作系统的核心功能。
一个进程通常具有以下三个核心要素:
程序映像:二进制指令序列。
地址空间:用于存放数据和执行程序。
PCB(Process Control Block,进程控制块):内核中描述进程的主要数据结构。
2.10 命令行参数
当运行程序时,如何向程序传递参数?
利用int argc和char *argv[]
argc表明命令行参数的数目;argv指向命令行参数。
2.11 环境列表
环境列表(简称环境)的形式
一个字符串(形式为name=value)数组。name被称为环境变量(environment variables)。
环境列表的属性
每个进程都有一个与其相关的环境。
新的进程会继承其父进程的环境(inherit a copy)。
一种进程间通信的方式(父进程→子进程)。
如何在程序中获得环境列表
char **environ
int main(int argc, char *argv[], char *envp[])
3.进程控制编程
3.1创建一个新进程fork()
include <unistd.h>
pid_t fork(void);
In parent: returns process ID of child on success, or –1 on error;
in successfully created child: always returns 0
当fork()顺利完成任务时,就会存在两个进程,每个进程都从fork()返回处开始继续执行。
3.2关于 fork的几点说明
两个进程执行相同的代码(text)段,但是有各自的堆栈(stack)段、数据(data)段以及堆(heap)。
子进程的stack、data、heap segments是从父进程拷贝过来的。
fork()之后,哪一个进程先执行(scheduled to use the CPU)不确定。
fork()之后,不能确定哪个进程先执行,会有什么隐患吗?
产生原因
fork()之后,不能确定是父进程还是子进程获得CPU。
危害
这种bugs很难被发现。
措施
如果需要确保特定的执行顺序,需要采用某种同步(synchronization)技术(semaphores,file locks...)。
3.3 进程终止
通常由8种方式使进程终止(terminate)
5种正常终止:
从main函数返回
调用exit
调用_exit或_Exit
最后一个线程从其启动例程(start routine)返回
最后一个线程调用pthread_exit
3种异常终止:
调用abort
接到一个信号并终止
最后一个线程对取消请求做出响应
不管进程如何终止,最后都会执行内核中的同一段代码:用于关闭所有打开的文件描述符,释放内存等。
3.4_exit()与exit()
这两个函数都用于正常终止一个进程:_exit立即进入内核,exit先执行一些清理处理(调用各终止处理程序、关闭所有标准I/O流等),然后进入内核。
#include <stdlib.h>
void exit(int status);
#include <unistd.h>
void _exit(int status);
这两个函数都有一个整型参数,称之为终止状态(exit status)。
注:shell中打印终止状态的命令为:echo $?
3.5进程的创建与终止
内核使程序执行的方法是调用一个exec函数。
进程自愿终止的方法是显示或隐式(通过exit)地调用_exit。
进程也可非自愿地由一个信号使其终止
3.6 exec函数族
Linux系统中有一系列的函数可以将一个进程的执行流程从一个可执行程序转移到另一个可执行程序,也就是装载并运行一个程序。这些函数通常被称为exec函数族:
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execv(const char *path, char *const argv []);
int execle(const char *path, const char *arg, ..., char *const envp[]);
int execve(const char *path, char *const argv[], char *const envp[]);
int execlp(const char *file, const char *arg, ...);
int execvp(const char *file, char *const argv[]);
注意:调用exec并不创建新进程,只是用一个全新的程序替换了当前进程的正文段、数据、堆和栈。
3.7exec函数族各函数的区别
第一个区别
前4个取路径名作为参数,后两个取文件名作为参数。
第二个区别
与参数传递有关(l表示list,v表示vector)。execl、execlp以及execle要求将新程序的每个命令行参数(command-line arguments)都指定为一个单独的参数,以NULL指针表明参数的结束。另外三个函数(execv、execvp和execve),首先须要建立一个指向各参数的指针数组,然后将该数组的地址作为这三个函数的参数。
第三个区别
与向新程序传递环境变量表有关。以e结尾的两个函数(execle和execve)可以传递一个指向环境字符串指针数组的指针。其它四个函数则使用调用进程中的environ变量为新进程复制现有的环境。
3.8 exec函数族的关系图
这6个函数中,通常只有execve是内核的系统调用,另外5个只是库函数,它们最终都要调用该系统调用。这6个函数的关系如下:
3.9监控子进程
父进程创建子进程后,如何知道子进程什么时候终止?如何知道子进程怎么终止(正常or异常)?
措施
wait()或waitpid()...
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
返回值:若成功返回进程ID,若出错返回-1。
调用wait或waitpid的进程可能发生的情况有:
如果所有子进程都还在运行,则阻塞(Block)。
如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
如果它没有任何子进程,则立即出错返回。
3.10 wait和waitpid区别
在一个子进程终止前,wait使其调用者阻塞,而waitpid有一个选项,可使调用者不阻塞。
waitpid并不等待在其调用之后的第一个终止的子进程。它有若干个选项,可以控制它所等待的进程。
如果一个子进程已经终止,并且是一个僵死进程,wait立即返回并取得该子进程的状态,否则wait使其调用者阻塞直到一个子进程终止。如果调用者阻塞并且它有多个子进程,则在其一个子进程终止时,wait就立即返回。因为wait返回终止子进程的ID,所以总能了解到是哪一个子进程终止了。
注:僵死进程(zombie),一个已经终止、但是其父进程尚未对其进行善后处理(获得终止子进程的有关信息,释放它仍占用的资)的进程被称为僵死进程。
3.11 终止状态的查看
有4个互斥的宏可以用来获取进程终止的原因:
WIFEXITED(status)
若子进程正常终止,该宏返回true。
此时,可以通过WEXITSTATUS(status)获取子进程的退出状态(exit status)。
WIFSIGNALED(status)
若子进程由信号杀死,该宏返回true。
此时,可以通过WTERMSIG(status)获取使子进程终止的信号值。
WIFSTOPPED(status)
若子进程被信号暂停(stopped),该宏返回true。
此时,可以通过WSTOPSIG(status)获取使子进程暂停的信号值。
WIFCONTINUED(status)
若子进程通过SIGCONT恢复,该宏返回true。
进程和文件的关系
3.12父子进程间的文件共享
执行完fork()以后,子进程会得到父进程文件描述符(file descriptors)的一份拷贝。
父、子进程的文件描述符指向同一个打开文件描述(open file description),其中包含当前文件偏移量(current file offset)和打开文件状态标志(open file status flags)。
4.信号
4.1信号基本概念
为什么进程运行时会出现“段错误”?为什么按下“Ctrl+C”会终止进程?
信号!
信号是内核提供的一种异步消息机制,主要用于内核对进程发送异步通知事件,可以理解为进程执行流程的一个“软中断”。
信号总是由内核递交给进程。但是从应用程序的角度来讲,信号的来源是多种多样的。
4.2常见的信号有哪些呢?
当进程在一个没有打开的管道上等待时,内核发出SIGPIPE信号。
进程在Shell中前台执行时,用户按下Ctrl+C组合键,将向进程发送SIGINT信号。
用户使用kill命令向某个进程发送信号。
进程访问非法的内存地址时,内核向其发送“段错误”信号SIGSEGV。
一个进程使用系统调用向另一个进程发送信号。
发生各种运行时异常(如浮点错误)时,内核将向进程发送SIGFPE信号。
...
4.3信号处理机制
在内核对进程进行管理的PCB信息块中有若干个字节,其中每个比特位用于表示某个信号是否发生。
当需要向某个进程发送一个特定的信号时,就将其PCB信息块中对应的比特位置为1。
对信号的处理并不会立刻发生,内核会在进程从内核态返回用户态时对当前进程的PCB中表示信号的数据进行检查,如果有信号发生,则内核会修改当前进程栈中的信息,使得返回用户态后首先执行与信号绑定的处理函数,然后再从当前进程被中断或进行系统调用的地方继续执行。
补充:信号何时被处理是应用程序无法预知的。信号处理函数虽然不在进程的正常执行流程中,但也是在用户态执行的代码,处于进程的上下文中,可以访问进程的虚拟地址空间。
4.4默认动作
当进程接收到一个信号时,可能执行的默认动作有:
信号被忽略(ignored)。
进程被终止(terminated)。也就是进程的异常终止。
生成一个核心转储文件(core dump file),并且进程被终止。
进程被暂停(stopped)——进程的执行被挂起。
进程的执行被恢复。
4.5 如何向进程发送信号?
进程也可以使用kill给自己发送信号,但在这种情况下,使用raise函数更方便:
#include <signal.h>
int raise(int sig);
使用alarm函数可以在一段指定的时间后给自己发送SIGALRM信号:
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
seconds参数表示一个以秒为单位的超时时间。超过这段时间后,内核将自动向进程发送SIGALRM信号。利用alarm函数可以实现定时操作。
4.6signal()
signal函数是Linux系统上传统的信号处理接口:
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal函数的作用就是将handler参数所指向的函数注册成为参数signum所代表的信号的处理函数。signal函数的返回值是这个信号原来的处理函数,如果返回SIG_ERR,则说明有错误发生。
注册成功后,所注册的函数就会在信号被处理时调用,代替了默认的行为,称为信号被捕捉。
使用signal函数时,应注意以下两点:
handler参数的值可以是SIG_IGN或SIG_DFL,SIG_IGN表示忽略这个信号,SIG_DFL表示对信号的处理重设为系统的默认方式。
有些信号是不可以忽略或捕获的,如SIGKILL和SIGSTOP。
4.7sigaction()
signal函数的使用方法比较简单,但不属于POSIX标准,在各类UNIX平台上的实现不尽相同,因此其用途收到了一定的限制。POSIX标准定义的信号处理接口是sigaction函数:
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
act表示要设置的对信号的新处理方式。oldact表示原来对信号的处理方式。函数执行成功返回0,失败返回-1。
4.8 struct sigaction类型
struct sigaction用来描述对信号的处理,定义如下:
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
}
sa_handler与signal函数中的信号处理函数类似。
sa_sigaction是另一个信号处理函数,可以获得关于信号的更详细的信息。
当sa_flags成员的值包含了SA_SIGINFO标志时,系统将使用sa_sigaction函数作为信号处理函数,否则使用sa_handler。
sa_mask成员用来指定在信号处理函数执行期间需要屏蔽的信号。当某个信号被处理时,它自身会被自动放入进程的信号掩码,因此在信号处理函数执行期间这个信号不会再度发生。
sa_flags成员用于指定信号的处理行为,它可以是以下值的“按位或”组合。
SA_RESTART:使被信号打断的系统调用自动重新发起。
SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到SIGCHLD信号。
SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到SIGCHLD信号,这时子进程如果退出也不会成为僵死进程。
SA_NODEFER:使对信号的屏蔽无效,即在信号处理函数执行期间仍能发生这个信号。
SA_RESETHAND:信号处理之后重新设置为默认的处理方式。
SA_SIGINFO:使用sa_sigaction成员而不是sa_handler作为信号处理函数。
sa_restorer成员是一个已废弃的数据域,一般不使用。
4.9