进程
进程和程序
进程是一个可执行程序的实例.
程序是包含了一系列信息的文件,这些信息描述了如何在运行时创建一个进程, 所包括的内容如下
- 二进制格式标识:每个程序文件都包含用于描述可执行文件格式的元信息。(a.out)
- 机器语言指令:对程序算法进行编码。
- 程序入口地址:标识程序开始执行时的启始指令位置。
- 数据:程序文件包含的变量初始值和程序使用的字面量值。
- 符号表及重定位表:描述程序中函数和变量的位置及名称。
- 其他信息
进程号和父进程号
每个进程都有唯一一个进程号,进程号是一个正数,用以唯一标识系统中的某个进程。对各种系统调用而言,进程号有时可以作为传入参数有时可以作为返回值。
- getpid()
#include<unistd.h>
pid_t getpid();
getpid()返回调用进程的进程号,linux限制进程号需要小于等于32767,创建心进程时内核会按顺序将下一个可用的进程号分配给其使用。每当进程号达到32767的限制时,内核将重置进程号技术器
一旦进程号达到 32767,会将进程号计数器重置为 300,而不是 1。之所以如此,是因为低数值的进程号为系统进程和守护进程所长期占用,在此范围内搜索尚未使用的进程号只会是浪费时间
每个进程都有一个创建自己的父进程。getppid()可以检索到自己的父进程号
#include<unistd.h>
pid_t getppid(void);
每个进程都有自己的父进程,进程号之间的属性反应了进程之间的树状关系。
进程 1 init进程,即所有进程的始祖。使用pstree(1)命令可以查看到这一家族树
若父进程终止,则子进程将变为孤儿,init将收留子进程对子进程的getppid()函数 将返回 1
进程内存布局
每个进程所分配的内存由很多部分组成,称之为“段”
- 文本段 包含了进程运行的程序机器语言指令。文本段具有只读属性,以防进程通过错误指针意外修改自身指令。 所以又将文本段设为可以共享,这样一份代码的拷贝可以映射到这些进程的虚拟地址空间中
- 初始化数据段 包含显式初始化的全局变量和静态变量. 当程序加载到内存时,从可执行程序中读取这些变量的值
- 未初始化数据段 包含了未进行显式初始化的全局变量和静态变量.程序启动之前,系统将本段内所有内存初始化为 0 ;
- 栈 (stack) 是一个动态增长和收缩的段 ,由栈帧组成,系统会为当前调用的函数分配一个栈帧.栈帧中存储了函数的局部变量.实参.返回值
- 堆 (heap) 是在可运行时为变量动态进行内存分配的一块区域
虚拟内存管理
linux 同现代很多内核一样,采用了虚拟内存管理技术,该技术利用了大多数程序访问的访问局部性,以求cpu和RAM资源.大多程序都展现了两种类型的局部性
- 空间局部性: 是指程序倾向与访问在最近访问过的内存地址附近的内存,
- 时间局部性: 是指程序倾向于在不久的将来再次访问最近刚访问过的内存地址(由于循环)
虚拟内存的规划之一是将每个应用程序使用的内存切割成小型的、固定大小的“页”单元,相依的,将RAM划分的一系列与虚拟页尺寸相同的页帧。任一时刻,每个程序仅有部分页需要存留在物理页帧中
这些页构成了驻留集。程序未使用的页拷贝保存在交换区,这是磁盘空间中的保留区域,作为计算机RAM的补充----仅在需要时才会载入物理内存。若进程想要访问的页面目前并未驻留在物理内存中,将会发生页面错误。内核将挂起进程,同时从磁盘中将页面载入内存
为支持这种方式,内核需要为每个进程维护一张页表 该页表描述了每页在进程虚拟地址空间中的位置 页表中的每个条目,要么指出一个虚拟页面在RAM中的位置,要么表明其当前驻留在磁盘上.
栈和栈帧
函数的调用和返回使栈的增长和收缩呈现性.栈驻留在内存的高端并向下增长,专用寄存器–栈指针.用于跟踪当前的栈顶.每次调用时,会在栈上新分配一帧,每当函数返回,则会从栈上将此帧移去
每当函数调用 则会为其分配栈帧,(递归调用自己同理)
环境变量
大多数shell通过export
命令向环境中添加变量
在哪进程创造时,会继承父进程的环境变量,二者通过环境变量来通信,在子进程创建后,二这可以更改各自的环境变量,而不会对对方在成影响
在程序中访问环境变量
C 语言中,可以使用char **environ
访问环境列表,(environ)与(argv)参数类似指向一个以NULL结尾的指针列表,每个指针又指向一个以空字符结尾的字符串
此外 还可以同过声明main函数的参数列表来访问环境列表
int main(int argc, char ** argv,char **envp)
- getenv()函数能够从进程环境中检索单个值.
#include<stdlib.h>
char *getenv(const char *name);
返回相应的字符串指针
如果指定 SHELL 为参数 name,那么将返回/bin/bash。如果不存在指定名称的环境变量,那么 getenv()函数将返回 NULL
修改环境
#include<stdlib.h>
int putenv(char* string);
参数 string 是一指针,指向 name=value 形式的字符串。调用 putenv()函数后,该字符串就成为环境的一部分,换言之,putenv 函数将设定 environ 变量中某一元素的指向与 string 参数的指向位置相同,而非 string 参数所指向字符串的复制副本。因此,如果随后修改 string 参数所指的内容,这将影响该进程的环境。出于这一原因,string 参数不应为自动变量(即在栈中分配的字符数组 ),因为定义此变量的函数一旦返回,就有可能会重写这块内存区域。
- putenv()函数的 glibc 库实现还提供了一个非标准扩展。如果 string 参数内容不包含一个等号(=),那么将从环境列表中移除以 string 参数命名的环境变量
#include<stdlib.h>
int setenv(const char *name,const char *value,int overwrite);
setenv()函数为形如 name=value 的字符串分配一块内存缓冲区,并将 name 和 value 所指向的字符串复制到此缓冲区,以此来创建一个新的环境变量。注意,不需要(实际上,是绝对不要)在 name 的结尾处或者 value 的开始处提供一个等号字符,因为 setenv()函数会在向环境添加新变量时添加等号字符。
执行非局部跳转
setjmp()_和_longjmp()
在一个深度嵌套的函数调用中发生了错误,需要放弃当前任务,从多层函数调用中返回
#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
setjmp()调用为后续longjmp()调用的执行确立了跳转目标. 该目标正是程序发起setjmp()调用的位置,
- 可以区分 setjmp 调用是初始返回还是第二次“返回”。初始调用返回值为 0,后续“伪”返回的返回值为 longjmp()调用中 val 参数所指定的任意值。
- 若指定的val为0,则函数会将二次返回的值调换为1;
setjmp()直接调用返回0 ;若从longjmp()处返回则返回longjmp()中指定的val.
使用longjmp()的第二个参数的原因是可以多个longjmp()对应一个setjmp()
- 若指定的val为0,则函数会将二次返回的值调换为1;
#include <stdio.h>
#include <setjmp.h>
static jmp_buf buf;
void second(void) {
printf("second\n"); // 打印
longjmp(buf,1); // 跳回setjmp的调用处 - 使得setjmp返回值为1
}
void first(void) {
second();
printf("first\n"); // 不可能执行到此行
}
int main() {
if ( ! setjmp(buf) ) {
first(); // 进入此行前,setjmp返回0
} else {
// 当longjmp跳转回,setjmp返回1,因此进入此行
printf("main\n"); // 打印
}
return 0;
}
程序输出
second
main
-
longjmp必须在setjmp调用之后,而且longjmp必须在setjmp的作用域之内。具体来说,在一个函数中使用setjmp来初始化一个全局标号,然后只要该函数未曾返回,那么在其它任何地方都可以通过longjmp调用来跳转到 setjmp的下一条语句执行。实际上setjmp函数将发生调用处的局部环境保存在了一个jmp_buf的结构当中,只要主调函数中对应的内存未曾释放 (函数返回时局部内存就失效了),那么在调用longjmp的时候就可以根据已保存的jmp_buf参数恢复到setjmp的地方执行。
-
setjmp()函数的使用限制
- SUSv3和C99规定,对setjmp()的调用只能在如下语境中使用
- if、switch、while等整个控制表达式.
- 作为一元操作符! 的操作对象,其最终表达式构成了选择或迭代语句的整个控制表达式
- 作为比较操作的一部分,另一操作对象必须是一个常量表达式
- 作为独立的函数调用,没有嵌入更大的表达式
int a = setjmp(env);/*WRONG!*/
之所以规定这些限制,是因为作为常规函数的 setjmp()实现无法保证拥有足够信息来保存所有寄存器值和封闭表达式中用到的临时栈位置,以便于在 longjmp()调用后此类信息能得以正确恢复。因此,仅允许在足够简单且无需临时存储的表达式中调用 setjmp()。
- 滥用longjmp()
如果将 env 缓冲区定义为全局变量对所用函数可见 , 那么就可以执行如下操作.
- 调用函数x(),使用setjmp()调用在全局变量env中建立一个跳转目标
- 从函数x()中返回.
- 调用函数y(),使用env变量调用longjmp()函数
这是一个严重错误,因为 longjmp()调用不能跳转到一个已经返回的函数中。思考一下,在这种情况下,longjmp()函数会对栈打什么主意—尝试将栈解开,恢复到一个不存在的栈帧位置,这无疑将引起混乱。如果幸运的话,程序会一死(crash)了之。然而,取决于栈的状态,也可能会引起调用与返回间的死循环,而程序好像真地从一个当前并未执行的函数中返回了。(在多线程程序中有与之相类似的滥用,在线程某甲中调用 setjmp()函数,却在线程某乙中调用 longjmp()。)
进程的创建
fork() exit() wait() execve()的简介
- fork()函数允许一进程创建一新进程.具体的子进程为父进程的拷贝,子进程拥有父进程的栈,数据段,堆,和执行文本段.
- exit(status)函数,终止一个进程将进程的所有资源归还内核,交给其进行再次分配.参数status为一整型变量,表示进程的退出状态.父进程可以用wait()函数来获取该状态
- wait(&status)函数 目的有二 如果子进程未调用exit()函数 那么wait函数会挂起父进程直到子进程终止;其二子进程的终止状态通过exit()函数返回
- 系统调用execve(pathname,argv,envp)加载一个新程序路径名为pathname,参数列表为argv,envp)到当前进程的内存。这将丢弃现存的文本段,并为新程序重新创建栈、数据段、堆。这一操作通常称为执行一个新程序。
fork()
创建一个新的子进程
#include<unistd.h>
pid_t fork(void);
理解 fork()的诀窍是,要意识到,完成对其调用后将存在两个进程,且每个进程都会从 fork()的返回处继续执行。这两个进程将执行相同的程序文本段,但却各自拥有不同的栈段、数据段以及堆段拷贝。子进程的栈、数据以及栈段开始时是对父进程内存相应各部分的完全复制。执行 fork()之后,每个进程均可修改各自的栈数据、以及堆段中的变量,而并不影响另一进程
- 不同的是 fork()在不同的进程中返回不同
fork()在父进程中返回0
在子进程中返回子进程的进程ID(pid_t)- //fork()创建失败后返回-1
调用 fork()之后,系统将率先“垂青”于哪个进程(即调度其使用 CPU),是无法确定的,意识到这一点极为重要。在设计拙劣的程序中,这种不确定性可能会导致所谓“竞争条件(racecondition)”的错误,24.2 节会对此做进一步说明
- //fork()创建失败后返回-1
父子进程之间的文件共享
父子进程之间共享文件属性的妙用屡见不鲜.父子进程同时写入一文件,共享文件偏移量会确保二者不会覆盖彼此的输出内容. 但这并不能阻止进程之间的输入混乱, 若要规避这一现象 需要进行进程同步.比如父进程可以调用wait()来暂停运行
vfork()//不建议?
pid_t vfork(void );
fork()和vfork()区别
- vfork会挂起父进程,直到vfork出的子进程进行_exit()或exec()操作
- 与fork()不同 vfork()的子进程与父进程共享内存,
- vfork()产生的子进程不应调用 exit()退出,因为这会导致对父进程 stdio 缓冲区的刷新和关闭
除非速度绝对重要的场合,新程序应当舍 vfork()而取 fork()。原因在于,当使用写时复制语义实现 fork()(大部分现代 UNIX 实现皆是如此)时,在速度几近于 vfork()的同时,又避免了 vfork()的上述怪异行止
进程的终止
exit()和_exit()
exit()函数会进行系统调用_exit() 并且exit()会关闭当前进程的所有文件描述符
fork().stdio缓冲区以及_exit()之间的交互
#include<stdio.h>
int main(int argc,char ** argv)
{
printf("hello world");
write(STDOUT_FILENO,"Ciao\n",5);
if(fork()==-1)
errExit("fork");
exit(EXIT_SUCCESS) ;
}
上述代码在输出定向到终端时,会看到预计结果
$ ./test
hello world
Ciao
但重定向输出到文件时,结果如下
$ ./test > a
$ cat a
Ciao
hello world
hello world
要理解为什么 printf()的输出消息出现了两次,首先要记住,是在进程的用户空间内存中(参考 13.2 节)维护 stdio 缓冲区的。因此,通过 fork()创建子进程时会复制这些缓冲区。当标准输出定向到终端时,因为缺省为行缓冲,所以会立即显示函数 printf()输出的包含换行符的字符串。不过,当标准输出重定向到文件时,由于缺省为块缓冲,所以在本例中,当调用 fork()时,printf()输出的字符串仍在父进程的 stdio 缓冲区中,并随子进程的创建而产生一份副本。父、子进程调用 exit()时会刷新各自的 stdio 缓冲区,从而导致重复的输出结果
- 采用以下方式来避免问题
- 针对stdio缓冲区解决 调用fork()前使用函数fflush()来刷新缓冲区 或者使用setvbuf()与setbuf()来关闭stdio流的缓冲功能
- 子进程调用_exit()系统调用 从而不再刷新stdio缓冲区
- 在创建子进程的应用中,典型情况下应仅有一个进程通过exit()函数终止,其他的子进程应通过_exit()终止,从而确保只有一个进程调用处理程序并刷新stdio缓冲区.
write()的输出并未出现两次 是因为write()调用会将数据直接传给内核缓冲区 在进行fork()时 不会复制这部分缓冲
write()的输出结果先于printf()而出现,是因为 write()会将数据立即传给内核高速缓存,而 printf()的输出则需要等到调用 exit ()刷新 stdio 缓冲区时
监控子进程
等待子进程
系统调用wait()
系统调用wait()等待调用进程的任一子进程终止,同时在参数status所指的缓冲区中返回该子进程的终止状态.
#include<sys/wait.h>
pid_t wait(int *status);
- 执行如下操作
- 调用进程无子进程终止,调用将一直阻塞,知道某个子进程exit,若调用时已有子进程终止,wait()即立即返回,
- 如果status非空,那么关于子进程的信息会通过status返回
- 内核会为父进程下所有子进程的运行总量追加进程的cpu时间以及资源使用数据
- 将终止进程的ID作为wait()的结果返回
while((childpid=wait(NULL))!=-1)
continue;
if(errno!=ECHILD)
errexit(errno);
如上代码将等待所有子进程退出后执行后续代码
系统调用waitpid()
wait()调用有着诸多限制,设计waitpid()则避免了这些限制
- 父进程已经创建了诸多子进程 ,wait将无法等待某个特定子进程的完成 ,只能顺序等待
- 没有子进程退出,wait()总是保持阻塞. 有时会希望执行非阻塞的等待.
- 使用wait()只能发现已经终止的子进程.对于子进程因某个信号
#include <sys/wait.h>
pid_t waitpid(pid_t pid,int *status,int options);
- pid 大于 0, 表示等待进程ID为pid的子进程
- pid 小于-1, 则会等待进程组标识符与pid绝对值相等的所有子进程
- pid 等于-1, 则等待任意子进程.
- wait(&status)调用与waitpid(-1,&status,0)等价
其中options是一个位掩码,可以是0 也可以包含多个如下标识(或操作)
- WNOHANG:如果没有任何已经结束的子进程则马上返回, 不予以等待.
- WUNTRACED:如果子进程进入暂停执行情况则马上返回, 但结束状态不予以理会. 子进程的结束状态返回后存于status, 底下有几个宏可判别结束情况.
- WNOHANG: 忽略子进程的返回状态 不阻塞;
- 一些宏
- WIFEXITED (status):如果子进程正常结束则为0 值.为真
- WEXITSTATUS(status):取得子进程exit()返回的结束代码, 一般会先用WIFEXITED 来判断是否正常结束才能使用此宏.
- WIFSIGNALED(status):如果子进程是因为信号而结束则此宏值为真.
- WTERMSIG (status):取得子进程因信号而中止的信号代码, 一般会先用WIFSIGNALED 来判断后才使用此宏.
- WIFSTOPPED (status):如果子进程处于暂停执行情况则此宏值为真. 一般只有使用WUNTRACED时才会有此情况.
- WSTOPSIG (status):取得引发子进程暂停的信号代码, 一般会先用WIFSTOPPED 来判断后才使用此宏.
wait() 和waitpid() 的区别
- wait等待第一个终止的子进程,而waitpid可以通过pid参数指定等待哪一个子进程。当pid=-1、option=0时,waitpid函数等同于wait,可以把wait看作waitpid实现的特例。
- waitpid函数提供了wait函数没有提供的三个功能:
- waitpid等待一个特定的进程,而wait则返回任一终止子进程的状态 。
- waitpid提供了一个 wait的非阻塞版本,有时希望取得一个子进程的状态, 但不想进程阻塞。
- waitpid支持作业控制。
等待状态值
wait()和waitpid()返回的status的值,可以用来区分以下子进程.
- 子进程exit退出终止
- 子进程受到未处理信号而终止
- 子进程因受到信号而停止,并以WUNTRACED标识调用waitpid().
- 子进程因收到信号SIGCONT而恢复,并以WCONTINUED标志调用waitpid().
上述所用情况都可以用 **“等待状态”**来涵盖, 而前两种则可以用 **“终止状态”**指代,
头文件<sys/wait.h>定义了下述宏 对各自wait()或waitpid()返回的status值进行处理时,以下列表中各宏会返回真值(只有一个)
-
WIFEXITED(status)
- 子进程正常结束则返回真(1).此时,宏 WEXITSTATUS 返回子进程的退出状态;
-
WIFSIGNALED(status)
- 若通过信号杀死子进程则返回真.此时,宏 WTERMSIG(status)返回导致子进程终止的信号编号,若子进程产生内核转储文件,则宏 WCOREDUMP(status)返回真值(true)
-
WIFSTOPPED(status)
- 若子进程因信号而停止,则此宏返回true.此时宏 WSTOPSIG(status)返回导致子的信号编号
-
WIFCONTINUED(status)
- 子进程收到SIGCONT而回复执行,则此宏返回真值true.
尽管上述宏的参数也以 status 命名,不过此处所指只是简单的整型变量,而非像wait()和 waitpid()所要求的那样是指向整型的指针
#define _GUN_SOURCE
#include <string.h>
#include <sys/wait.h>
void printWaitStatus(const char *msg, int status)
{
if (msg != NULL)
printf("%s", msg);
if (WIFEXITED(status))
{
printf("child exited ,status=%d\n", WEXITSTATUS(status));
}
else if (WIFSIGNALED(status))
{
printf("child killed by signal %d (%s)",WTERMSIG(status),strsignal(WTERMSIG(status));
#ifdef WCOREDUMP
if(WCOREDUMP(status))
printf("(core dumped)");
#endif
printf("\n");
}
else if (WIFSIGNALED(status))
{
printf("child stopped by signal %d (%s)\n," WSTOPSIG(status), strsignal(WSTOPSIG(status)));
#ifdef WIFCONTINUED
}
else if (WIFCONTINUED(status))
{
printf("child continue\n");
#endif
}
else
{
printf("what happened to this child?(status=%x)\n", (unsigned int)status);
}
}
从信号处理程序中终止程序
默认情况下某些信号会终止进程,有时希望在进程终止前执行一些清理步骤. 为此可以处理程序来捕捉这些信号,随即执行清理步骤 但若这么做 父进程 依然可以通过wait()和waitpid()获得到子进程的终止状态 如子程序获取信号后 调用_exit 则在父进程中监控的程序状态则为正常终止的
如果需要通知父进程自己因某个信号而终止,那么子进程的信号处理程序应首先将自己废除,然后再次发出相同信号,该信号这次将终止这一子进程。信号处理程序需包含如下代码
void handler(int sig)
{
/* perform cleanup */
signal(sig, SIG_DFL);
raise(sig);
}
僵尸进程
子进程若不被父进程wait()退出
则会释放大部分进程资源,只保留进程表中的信息. 转化为僵尸进程
若进行wait退出 则在进程表中的数据将会删除
若父进程被杀死 则同样在进程表中的数据会被删除
在一个父进程长期存在的程序中
若存在大量僵尸进程 则会占满进程表 届时将无法创建进程
SIGCHLD建立信号处理程序
无论一个子进程于何时终止 系统都会向父进程发送SIGCHLD信号
我们可在父进程中针对SIGCHLD信号编写处理僵尸进程的信号处理程序
但当调用信号处理程序时,会暂时将引发调用的信号阻塞起来(除非为 sigaction()指定了 SA_NODEFER 标志),且不会对 SIGCHLD 之流的标准信号进行排队处理。SIGCHILD这样一来,当信号处理程序正在为一个终止的子进程运行时,如果相继有两个子进程终止,即使产生了两次 SIGCHLD 信号,父进程也只能捕获到一个
解决方案是在信号处理程序中内部循环以WNOHANG标志来调用waitpid(),直至再无其他终止的子进程需要处理为止
通常SIGCHLD处理程序都简单的由以下代码组成 /仅捕获已终止子进程而不关心其退出状态
while(waitpid(-1,NULL,WNOHANG)>0);
程序的执行
exec()族关系
前4位 | 第五位 | 第六位 |
---|---|---|
都为exec | l:参数以列举的方式传递 | e:可以传递环境变量 |
都为exec | v:参数以结构体指针的方式传递 | p:可执行文件查找方式为文件名 |
int execl(const char *pathname, const char *arg, ...);
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg, ..., const char *envp[]);
int execlp(const char *filename, const char *arg, ...);
int execve(const char *pathname, char *const arg, ..., const char *envp[]);
int execvp(const char *filename, char *const argv[]);
// 各个函数的定义
// ... 是指以 "..." 列出的传入的arg参数
// 举例
int main(int argc, char **argv)
{
char *const ps_argv[] = {
"ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL};
char *const ps_envp[] = {
"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execv("/bin/ps", ps_argv);
execle("/bin/ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL, ps_envp);
execve("/bin/ps", ps_argv, ps_envp);
execlp("ps", "ps", "-o", "pid,ppid,pgrp,session,tpgid,comm", NULL);
execvp("ps", ps_argv);
execl("/home/wang/Desktop/object/test/", "-a", "-b", "--help",NULL);
}
- exec函数族使用注意点
在使用exec函数族时,一定要加上错误判断语句。因为exec很容易执行失败,其中最常见的原因有:
-
找不到文件或路径,此时errno被设置为ENOENT。
-
数组argv和envp忘记用NULL结束,此时errno被设置为EFAULT。
-
没有对应可执行文件的运行权限,此时errno被设置为EACCES。
-
exec后新进程保持原进程以下特征
- 环境变量(使用了execle、execve函数则不继承环境变量); - 进程ID和父进程ID; - 实际用户ID和实际组ID; - 附加组ID; - 进程组ID; - 会话ID; - 控制终端; - 当前工作目录; - 根目录; - 文件权限屏蔽字; - 文件锁; - 进程信号屏蔽; - 未决信号; - 资源限制; - tms_utime、tms_stime、tms_cutime以及tms_ustime值。
对打开文件的处理与每个描述符的exec关闭标志值有关,进程中每个文件描述符有一个exec关闭标志(FD_CLOEXEC),若此标志设置,则在执行exec时关闭该描述符,否则该描述符仍打开。除非特地用fcntl设置了该标志,否则系统的默认操作是在exec后仍保持这种描述符打开,利用这一点可以实现I/O重定向。
管道和FIFO
在进程之间通信
- 在不同进程中 其实并不知道管道的存在 他们只是从文件输入符读入或输出数据
- 一个管道是一个字节流 无法通过iseek()来进行随机访问 如果需要在管道中实现离散消息的概念 那么需要在应用程序中完成这些工作 虽然可行但遇到这种需求 最好使用IPC机制 如消息队列和数据报socket
- 试图从一个空的管道中读取数据将会被阻塞 直到至少有一个字节被写入到管道中 如果管道的写入端被关闭了 那么从管道中读取数据的进程在读完管道中剩余数据时将会看到文件结束(read()返回0)
- 管道是单向的
- 在管道中数据传输的方向是单向的.管道的一段用于写入 另一端用于读取
- 可以确保写入不超过PIPE_BUF字节的操作是原子的
- 如果多个进程写入同一管道,若它们在一个时刻写入的数据量不超过PIPE_BUF字节,那么就可以确保写入的数据不会发生相互混合的情况
- 在Linux中,PIPE_BUF的大小是65536字节
- 管道实际是一个在内核中维护的缓冲器
创建和使用管道
#include<unistd.h>
int pipe(int filedes[2])
成功的pipe将会在filedes[2]中返回两个打开的文件描述符
其fd[1]表示管道的读取端
fd[0]表示管道的写入端
也可以在管道中使用stdio函数(printf()\scanf()
) 只需要首先使用fdopen()
获取一个与fd[]
中某个相应的文件描述符对应的文件流即可 但此时要注意stdio的缓冲问题
管道允许相关进程间的通信
目前为止本章已经介绍了如何使用管道来让父进程和子进程之间进行通信,其实管道可以用于任意两个(或更多)相关进程之间的通信,只要在创建子进程的系列 fork()调用之前通过一个共同的祖先进程创建管道即可。(这就是本章开头部分所讲的“相关进程”的含义。)如管道可用于一个进程和其孙子进程之间的通信。第一个进程创建管道,然后创建子进程,接着子进程再创建第一个进程的孙子进程。管道通常用于两个兄弟进程之间的通信——它们的父进程创建了管道,然后创建两个子进程。这就是在构建管道线时 shell所做的工作。
- 关闭未使用的管道文件描述符
- 关闭管道的文件描述符不仅仅确保了进程不会耗尽其文件描述符的限制
- 如果读取进程没有关闭管道的写入端 那么在其他进程关闭了写入端之后 读者也不会看到文件结束 即使它读完了管道的数据 read()调用将看到此管道还有至少还存在一个管道的写入描述符打开这
如果读进程没有关闭写的文件描述符,那么即使写进程已经关闭了写入的描述符,读进程执行read的时候还是到不了文件结束,会保持阻塞,因为内核知道仍有一个写入的描述符开着。如果写进程没有关闭读的文件描述符,那么即使读进程已经已经关闭了读的描述符,写进程仍然能够往管道里写入,最终的结果就是写满管道然后阻塞。正常情况,也就是当没有读的文件描述符存在时,写入管道操作会让当前进程收到SIGPIPE信号
与前面使用信号来同步相比
- 使用管道同步具备的优势:>
- 可以用来协同与其相关进程动作 (信号无法排队 不适于此种)
- 使用信号同步具备的优势:>
- 它可被一个进程广播到进程组的所有成员处
FIFO “命名管道”
也是"有名管道" (First In First Out ==> FIFO)
FIFO和pipe之间最大的区别是FIFO在文件系统中拥有一个名称 并且其打开方式与打开一个普通文件是一样的.
这样就能够将FIFO用于非相关进程之间的通信(客户端/服务器)
与管道相同 FIFO也拥有一个写入端和读取端 并且从管道中读取的顺序和写入顺序是相同的
-
和pipe相同 当所有FIFO的描述符都被关闭后 所有未被读取的数据会被丢弃
使用mkfifo [-m mode] pathname
在shell中创建一个FIFO
pathname 是FIFO的名称 -m 选项用来指定mode 其工作方式与chmod命令一样
当在FIFO(或管道)上调用fstat()和stat()函数时他们会在stat的st_mode字段中返回一个类型为S_IFOFO的文件 -
创建FIFO
#include<sys/stat.h>
int mkfifo(const char *pathname,mode_t mode);
//return 0 on success, -1 on error
mode
参数指定了FIFO的权限 其mode和open()
的mode相同
FIFO一但被创建任何进程都可以打开 (能够通过常规文件权限检测)
- FIFO将同步读取进程和写入进程(在
open()
时阻塞__read()调用和write()调用同样要阻塞 等待对方)- 读进程打开FIFO的读端时将阻塞 直到有进程打开了FIFO的写端
- 同理 写进程在打开FIFO的写端时将阻塞 直到有进程打开了FIFO的读端
FIFO打开时只能指定O_RDONLY/O_WRONLY 指定O_RDWR打开破坏了FIFO的io模型 需要避免
以非阻塞打开FIFO
在open()FIFO时设置非阻塞O_NONBOLCK
如果fifo的另一端被打开 那么O_NONBOLCK
不会对open()有任何影响
- 在FIFO另一端未被打开时 O_NONBOLCK 才会有作用
- 打开FIFO是为了读取 并且写入端已经打开 那么open()调用会立即成功
- 若打开FIFO是为了写入并且还没有打开读端那么open()调用将会失败 并将errno设置为ENXIO
目的
- 他允许单个进程打开一个FIFO的两端 这个进程会在打开FIFO时指定O_NONBOLCK标记以读数据,接着打开FIFO以便写入数据
- 他防止打开两个FIFO的进程之间产生死锁
阻塞和非阻塞
- 特点一:不指定O_NONBLOCK(即open没有位或O_NONBLOCK)
- open以只读方式打开FIFO时,要阻塞到某个进程为写而打开此FIFO
- open以只写方式打开FIFO时,要阻塞到某个进程为读而打开此FIFO。
- open以只读、只写方式打开FIFO时会阻塞,调用read函数从FIFO里读数据时read也会阻塞。
- 调用write函数向FIFO里写数据,当缓冲区已满时write也会阻塞。
- 通信过程中若写进程先退出了,则调用read函数从FIFO里读数据时不阻塞;若写进程又重新运行,则调用read函数从FIFO里读数据时又恢复阻塞。
- 通信过程中,读进程退出后,写进程向命名管道内写数据时,写进程也会(收到SIGPIPE信号)退出
- 特点二: 指定O_NONBLOCK
- 先以只读方式打开: 如果没有进程为写而打开一个FIFO,只读open()成功并且open()不阻塞
- 先以只写方式打开: 如果没有进程为读而打开一个FIFO,只写open()出错返回-1;
- read、write()调用将不阻塞
- 通信过程中,读进程退出后,写进程向命名管道内写数据时,写进程也会(收到SIGPIPE信号)退出.
tee命令
shell管道线其中一个特征是他们是线性的,管道线中的每个进程都读取上个进程的数据并将数据发送到后一个进程中
使用FIFO就能够在管道线中创建子进程 并通过tee命令
这样就还能将前一个进程的输出复制一份写入到命令参数指定的文件中
$ mkfifo myfifo
$ wc -l < myfifo & #在后台阻塞着
$ ls -l | tee myfifo | sort -k5n # 将ls -l 复制一份 写到fifo里
# 当FIFO里有数据了 后台的wc进行执行
一个system()的自主实现
不进行信号屏蔽版shell.0.0.2.c
int my_system(char *command)
{
int status;
pid_t childPid;
switch (childPid = fork())
{
case /* constant-expression */ -1:
/* code */ return -1;
break;
case 0:
execl("/usr/bin/zsh", "zsh", "-c", command, NULL);
_exit(127);
default:
if (waitpid(childPid, &status, 0) == -1)
return -1;
else
return status;
}
}
不进行信号屏蔽可能会引发竞争状态 下文有解释
进行信号屏蔽版shell.0.0.2.c
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <stdio.h>
#include <wait.h>
#include <stdlib.h>
#include <string.h>
int my_system(const char *command)
{
sigset_t blockMaxk, origMask;//声明信号集
struct sigaction saIgnore, saOrigQuit, saOrigInt, saDefault;//声明sigaction结构体存放 先前的 目标的 信号处理程序
pid_t childPid;
int status, savedErrno;
// 声明savedErrno 是存放先前的errno值 避免在执行system时 将errno的值修改
if (command == NULL) return system(":") == 0;
sigemptyset(&blockMaxk);//将信号集初始化为空
sigaddset(&blockMaxk, SIGCHLD);//将signo添加到信号集中
sigprocmask(SIG_BLOCK, &blockMaxk, &origMask);
//将原先的信号屏蔽字存储在origMask中 将blockMask中的信号设置为当前的信号屏蔽字
saIgnore.sa_handler = SIG_IGN;//将saIgnore的信号处理程序设置为忽略信号
saIgnore.sa_flags = 0;//信号掩码为0
sigemptyset(&saIgnore.sa_mask);//将saIgnore的信号集合设置为空
sigaction(SIGINT, &saIgnore, &saOrigInt);
sigaction(SIGQUIT, &saIgnore, &saOrigInt);
//将原来对信号的操作保存到 saOrinInt 结构体中
//将新的信号处理程序改为 saIgnore
switch (childPid = fork())
{
case -1:
status = -1;
break;
case 0:
saDefault.sa_handler = SIG_DFL;
saDefault.sa_flags = 0;
sigemptyset(&saDefault.sa_mask);
if (saOrigInt.sa_handler != SIG_IGN)
sigaction(SIGINT, &saDefault, NULL);
if (saOrigInt.sa_handler != SIG_IGN)
sigaction(SIGQUIT, &saDefault, NULL);
sigprocmask(SIG_SETMASK, &origMask, NULL);
execl("/bin/sh", "sh", "-c", command, (char *)NULL);
_exit(127);
default:
while (waitpid(childPid, &status, 0) == -1)
{
/* code */
if (errno != EINTR)
{
status = -1;
break;
}
}
break;
}
savedErrno = errno;
sigprocmask(SIG_SETMASK, &origMask, NULL);
sigaction(SIGINT, &saOrigInt, NULL);
sigaction(SIGQUIT, &saOrigInt, NULL);
errno = savedErrno;
return status;
}
为什么要在执行system()函数时进行信号屏蔽
- 在system()内部正确处理信号
给 system()的实现带来复杂性的是对信号的正确处理。
首先需要考虑的信号是 SIGCHLD。假设调用 system()的程序还直接创建了其他子进程,
对 SIGCHLD 的信号处理器自身也执行了 wait()。在这种情况下,当由 system()所创建的子进程退出并产生 SIGCHLD 信号时,在 system()有机会调用 waitpid()之前,主程序的信号处理器程序可能会率先得以执行(收集子进程的状态)。这是竞争条件的又一例证。这会产生两种不良后果。
- 调用程序会误以为其所创建的某个子进程终止了。
- system()函数却无法获取其所创建子进程的终止状态。
所以,system()在运行期间必须阻塞 SIGCHLD 信号。
简而言之 即system()也有可能创建子进程 其子进程返回时产生的SIGCHLD信号有可能会被调用system()的进程的信号信号处理程序所捕获 但system()同时也想捕获子进程的退出状态
引发了 竞争状态
所以 要进行信号SIGCHLD的屏蔽