大纲
*进程标识符pid
类型是pid_t,一般是有符号的16位整型数。进程号是顺次向下被取得使用的,和进程标识符优先取最小的使用不一样。
getpid
返回当前进程的pid
pid_t getpid(void);
getppid
返回当前进程的父进程的pid
pid_t getppid(void);
*进程的产生fork
通过复制当前的进程来创建一个子进程。注意复制,是一模一样,甚至连运行到的位置都一样,但是也会存在几点不一样,比如fork返回值不一样、pid/ppid不同、味觉信号和文件锁不继承、资源利用率归零。
init进程:是所有进程的祖先进程,pid是1号
pid_t fork(void);
如果成功,将会在父进程中返回子进程的pid,在子进程中返回0。如果失败将在父进程中返回-1,不生成子进程,并设置errno。
使用样例
样例1
int main()
{
pid_t ret;
printf("[%d] Begin!\n", getpid());
ret = fork();
if (ret < 0 )
{
perror("fork()");
exit(1);
}
else if (ret == 0) //child
printf("[%d] child is working\n", getpid());
else if (ret > 0) //parent
printf("[%d] parent is working\n", getpid());
printf("[%d] End!\n", getpid());
exit(0);
}
输出:
[228] Begin!
[228] parent is working
[228] End!
[229] child is working
[229] End!
可以看到这里先输出了parent后输出child,但是这并不绝对,有可能是fork后父进程继续执行,子进程挂起;也有可能fork后优先执行子进程,然后父进程挂起。这取决于内核的进程调度算法。
注意这里只输出了一个Begin。但如果这样写命令:./fork > show.txt
,那将会这样输出:
[245] Begin!
[245] parent is working
[245] End!
[245] Begin!
[246] child is working
[246] End!
这里输出了两个Begin,为什么呢?因为如果输出到标准输出上,默认是行缓冲,printf("[%d] Begin!\n", getpid());
这句最后的换行符使得缓冲区的内容被刷新。现在重定向到了show.txt就变为了全缓冲,输出Begin的printf的换行符不会再刷新缓冲区,当fork时缓冲区的内容也被复制,所以子进程自己的缓冲区中也有了一个begin。所以在fork之前最后加上fflush非常重要。
样例2
父进程生成子进程,每个子进程判断一个数是否为质数
int main()
{
pid_t pid;
for (int i = 100; i <= 105; i++)
{
pid_t pid = fork();
if (pid < 0)
{
perror("fork()");
exit(1);
}
if (pid == 0)
{
int mark = 1;
for (int j = 2; j * j <= i; j++)
{
if (i % j == 0)
{
mark = 0;
break;
}
}
if (mark)
printf("%d is a prime\n", i);
//A
exit(0);
}
}
//B
exit(0);
}
有一点要注意,注释A下方的exit(0)一定不能忘记写。如果不写子进程不会退出,而会继续循环然后fork自己的子进程,自己的子进程又会fork子进程…
如果只在代码A处写上sleep(1000),利用ps auf指令会看到进程状态如下:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
yzq 1109 0.0 0.0 2360 76 pts/0 S 01:11 0:00 ./fork3
yzq 1108 0.0 0.0 2360 76 pts/0 S 01:11 0:00 ./fork3
yzq 1107 0.0 0.0 2492 76 pts/0 S 01:11 0:00 ./fork3
yzq 1106 0.0 0.0 2360 76 pts/0 S 01:11 0:00 ./fork3
yzq 1105 0.0 0.0 2492 76 pts/0 S 01:11 0:00 ./fork3
yzq 1104 0.0 0.0 2360 76 pts/0 S 01:11 0:00 ./fork3
在A处加上sleep导致父进程会先于子进程终止,最后fork的六个子进程就都变成了孤儿进程而被init回收,init等孤儿进程sleep完之后对它们完成状态收集。
如果只在代码B处写上sleep(1000),进程状态如下显示:
yzq 1202 0.0 0.0 2360 516 pts/0 S+ 01:13 0:00 \_ ./fork3
yzq 1203 0.0 0.0 0 0 pts/0 Z+ 01:13 0:00 \_ [fork3] <defunct>
yzq 1204 0.0 0.0 0 0 pts/0 Z+ 01:13 0:00 \_ [fork3] <defunct>
yzq 1205 0.0 0.0 0 0 pts/0 Z+ 01:13 0:00 \_ [fork3] <defunct>
yzq 1206 0.0 0.0 0 0 pts/0 Z+ 01:13 0:00 \_ [fork3] <defunct>
yzq 1207 0.0 0.0 0 0 pts/0 Z+ 01:13 0:00 \_ [fork3] <defunct>
yzq 1208 0.0 0.0 0 0 pts/0 Z+ 01:13 0:00 \_ [fork3] <defunct>
这里fork3作为父进程,fork了6个子进程。因为父进程有sleep(1000),所以子进程肯定会先于父进程终止,但是父进程还在sleep,没有采取什么措施来对这些子进程进行善后,这6个子进程就变成了“僵尸进程”。等父进程sleep完exit后,这6个进程就变了孤儿进程,再由init接管。
出现僵尸进程很正常,但是正常应该一闪即逝,如果一直存在那么其进程号就会一直被占用。系统的进程号有限,产生大量的僵尸进程,就会因为没有可用的进程号而导致系统不能产生新的进程。所这里的代码并不完善,下一章的资源释放可以解决僵尸进程的问题。
vfork
如果父进程有很多数据,fork一个子进程只做了很简单的一些操作比如打印一句话,那么拷贝父进程的数据就是吃力不讨好。由此出现了vfork,vfork的子进程共享父进程的地址空间。vfork还有个限制,子进程生成后,父进程在vfork中被内核挂起,直到子进程exec或_exit。并且在此之前,子进程不能从调用vfork的函数中返回(同时,不能修改栈上变量、 不能继续调用除_exit或exec系列之外的函数,否则父进程的数据可能被改写)
但是现在的fork加入了写时拷贝技术:fork后父子会共用同一个数据块,如果某个进程要对数据块进行改写,此时该进程才会拷贝一份地址空间,然后在拷贝的地址空间上进行修改。这个技术导致vfork逐渐淘汰
*进程的消亡及资源释放
wait
当进程一旦调用wait,就会立刻阻塞自己,由wait自己分析当前进程的某个子进程是否已经退出,如果找到了某个已经变成僵尸的子进程,wait就会回收这个子进程的信息,然后销毁最后返回被收集的子进程的ID;如果没有僵尸进程,则wait会一直阻塞直到僵尸进程出现。
pid_t wait(int *wstatus);
参数wstatus用来保存被收集进程退出时的一些状态,配合一些宏使用。如果不在意子进程如何被回收,可以设置为NULL。
- WIFEXITED(wstatus)
正常终止子进程返回真 - WEXITSTATUS(wstatus)
只能在WIFEXITED为真时使用,可以用这个宏提取子进程返回的值 - WIFSIGNALED(wstatus)
如果子进程是被一个信号终止的,则返回真
如果成功,wait会返回被收集的子进程的进程ID,如果调用进程没有子进程,调用就会失败,此时wait返回-1
waitpid
可以看到wait不能收集一个指定的进程信息,但是waitpid可以。waitpid除了回收指定进程外还有一个更有用的选项:option,提供了一些额外的选项来控制waitpid
pid_t waitpid(pid_t pid, int *wstatus, int options);
- pid > 0时,只回收进程ID等于pid的子进程,不管其它已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就会一直等下去。
- pid = 0时,回收同一个组中的所有子进程,如果子进程已经加入了别的进程组,waitpid不会对它做任何操作
- pid = -1时,回收任何一个子进程
- pid < -1时,回收一任何子进程,这个进程所属组的ID是pid的绝对值。
而option有个常用的WNOHANG,如果没有子进程退出,将会立即返回,不会像wait一样阻塞等待僵尸进程出现。
所以wait(int *wstatus);
等同于waitpid(-1, &wstatus, 0);
进程分配
之前求质数的代码中用6个子进程求六个数,其实这种实现非常不好。如果这里不止6个,而是6000、60000,将会占用非常多的进程号,甚至可能不够用。所以有几个方法来优化:
分块法
只需要几个进程(假设3个),然后将需要计算的数平均分成3份,每份交给对应的进程计算
可以看到每个进程负载程度不一样,质数在值偏小的地方分布比较密集,所以进程1的计算量比2、3大很多。
交叉分配
第一个数给1,第二个数给2,第三个数给3,第四个数给1…这样按顺序分
可以看到分配给进程1的数是0、3、6、9…到后面都是3的倍数,说明都不是质数,也就是说到后面进程1一个质数都没有,分配还是不均。这里因为数值的特殊性导致分配不均,但是交叉分配是使用比较广泛的一种分配方法
交叉分配法演示:
int main()
{
pid_t pid;
for (int n = 0; n < N; n++)
{
pid = fork();
if (pid < 0)
{
perror("fork()");
/*
这里应该写上回收前面子进程的代码
*/
exit(1);
}
if (pid == 0) //child
{
for (int i = LEFT + n; i <= RIGHT; i += N)
{
int mark = 1;
for (int j = 2; j * j <= i; j++)
{
if (i % j == 0)
{
mark = 0;
break;
}
}
if (mark)
printf("[%d]: %d is a prime\n", n, i);
}
exit(0);
}
for (int n = 0; n < N; n++)
wait(NULL);
exit(0);
}
进程池
上游丢下质数放到一个容器里,子进程们在容器中抢任务
这是能者多劳的做法,如果进程拿到非质数可以多算几个,拿到质数可以少算几个。但是要考虑进程间的通信和竞争
*exec函数族
fork函数是用于创建一个子进程,该子进程几乎是父进程的副本,而有时我们希望子进程去执行另外的程序,exec函数族就提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代调用它的子进程的数据段、代码段和堆栈段,在执行完之后,调用进程的内容除了pid外,其他全部被新程序的内容替换了。另外,这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。
int execl(const char *pathname, const char *arg, ...
/* (char *) NULL */);
int execle(const char *pathname, const char *arg, ...
/*, (char *) NULL, char *const envp[] */);
int execv(const char *pathname, char *const argv[]);
int execlp(const char *file, const char *arg, ...
/* (char *) NULL */);
int execvp(const char *file, char *const argv[]);
pathname表示要启动程序的名称包括路径名,arg参数表示启动程序所带的参数,一般是第一个参数要执行的命令名,不用带路径且arg必须以NULL结束
如果任何exec函数有返回值,说明失败了(成功了就被新的进程替换了),会返回-1并设置error
使用样例
样例1
int main(int argc, char *argv[])
{
puts("Begin!");
fflush(NULL); //别忘了fflush,和fork一样
execl("/bin/date", "date", "+%s", NULL);
perror("execl()");
exit(1);
puts("End!");
exit(0);
}
最后的end根本不会输出,因为这个进程调用了execl后已经被替换为date了
样例2
现在让父进程fork子进程,在子进程里调用execl,最后父进程回收子进程,同时输出End
int main()
{
puts("Begin");
fflush(NULL);
int pid = fork();
if (pid < 0)
{
perror("fork()");
exit(1);
}
if (pid == 0) //是子进程
{
execl("/bin/date", "date", "+%s", NULL);
perror("execl()");
exit(1);
}
else if (pid > 0)
wait(NULL);
puts("End");
exit(0);
}
用户权限及组权限
普通用户无法查看shadow文件,但是却可以利用passwd指令修改自己的密码,要修改密码又必须使用shadow文件,这不是矛盾了吗?
先学点概念。一个进程里存储三个userID,分别是real user id、effective user id和saved set-user-id。其中real user id就是真正启动进程的user id,就是谁调用的,一般不会被更改。如果一个可执行文件的ser-user-id bit=ON,当一个进程exec这个可执行文件后,进程的effective user id将会是这个可执行文件的属主id,权限也变成和这个属主id一样。换句话说,effective user id决定了进程访问文件的权限。而saved set-user-id则是effective user ID的副本,在执行exec调用时后能重新恢复原来的effectiv user ID。
现在看刚开始提出的问题。在shell中使用passwd指令,首先shell会fork一个子进程,然后exec可执行文件passwd,由于passwd设置了set-user-id位,而属主id是root,所以进程将会以root执行这个passwd
函数族
uid_t getuid(void); //返回real user id
uid_t geteuid(void); //返回effective user id
gid_t getgid(void); //返回real group id
gid_t getegid(void); //返回effective group id
int setuid(uid_t uid);
int setgid(uid_t uid);
int seteuid(uid_t euid);
int setegid(gid_t egid);
int setreuid(uid_t ruid, uid_t euid); //交换real uid和effective uid
int setregid(gid_t rgid, gid_t egid); //交换real gid和effective gid
使用样例
实现一个mysu,输入./mysu 0 cat /etc/shadow
就可以用root查看shadow文件
int main(int argc, char *argv[])
{
if (argc < 3)
{
fprintf(stderr, "Usage: %s <userID> <cmd>\n", argv[0]);
exit(1);
}
pid_t pid = fork();
if (pid < 0)
{
perror("fork()");
exit(1);
}
if (pid == 0)
{
setuid(atoi(argv[1])); //设置effective user id为argv[1]的属主id
execvp(argv[2], argv + 2);
perror("execvp()");
exit(1);
}
else if (pid > 0)
wait(NULL);
exit(0);
}
system
用于执行一个shell命令,内部通过/bin/sh -c <cmd>
实现,其实是:
system("date +%s");
//相当于下面三个函数的封装
fork()
execl("/bin/sh", "sh", "-c", "date + %s", NULL);
wait(NULL);
进程会计
一个进程消亡时将信息存到filename指定的结构体中
int acct(const char *filename);
进程时间
用time命令可以测得执行一个指令所消耗的时间,而time命令在内部是用times函数实现的
clock_t times(struct tms *buf);
struct tms
{
clock_t tms_utime; //命令在用户态执行时间
clock_t tms_stime; //命令在核心态执行时间
clock_t tms_cutime; //tms_utime+子进程的tms_utime时间
clock_t tms_cstime; //tms_cstime+子进程的tms_cstime时间
};
守护进程
先学点概念。我们常见的 Linux session(会话)一般是指 shell session。Shell session是终端中当前的状态,在终端中只能有一个 session。当我们打开一个新的终端时,总会创建一个新的 shell session。就进程间的关系来说,session 由一个或多个进程组组成。一般情况下,来自单个登录的所有进程都属于同一个 session。当创建一个进程时,它和父进程在同一个进程组、session 中。我们可以通过下图来理解进程、进程组和 session 之间的关系:
会话是由会话中的第一个进程创建的,一般情况下是打开终端时创建的 shell 进程。该进程也叫 session 的领头进程。Session 中领头进程的 PID 也就是 session 的 SID。
进程组又被称为job,有一个 job 会成为 session 的前台 job(foreground),其它的 job 则是后台 job(background)。每个 session 连接一个控制终端(control terminal),控制终端中的输入被发送给前台 job,从前台 job 产生的输出也被发送到控制终端上。
在子进程中调用setsid()后,子进程成为新会话首进程
pid_t setsid(void);
该函数可以建立一个新的会话,前提是调用setsid的进程不是一个进程组的组长(如果父进程fork一个子进程,则父进程是这个组的组长,所以只有子进程可以调用)。调用函数之后子进程将会变成该新建会话的领头进程,同时还变成该会话中新的进程组的组长
守护进程创建步骤:
1. 创建子进程,父进程退出
所有工作在子进程中进行
形式上脱离了控制终端
2. 在子进程中创建新会话
setsid()函数
使子进程完全独立出来,脱离控制
3. 改变当前目录为根目录
chdir()函数
防止占用可卸载的文件系统
也可以换成其它路径
4. 重设文件权限掩码
umask()函数
防止继承的文件创建屏蔽字拒绝某些权限
增加守护进程灵活性
5. 关闭文件描述符
继承的打开文件不会用到,浪费系统资源,无法卸载
getdtablesize()
返回所在进程的文件描述符表的项数,即该进程打开的文件数目
使用样例
int main()
{
if (daemonize())
exit(1);
FILE *fp = fopen("./test.txt", "w");
if (fp == NULL)
{
perror("fopen()");
exit(1);
}
for (int i = 0; ; i++)
{
fprintf(fp, "%d\n", i);
fflush(fp);
sleep(1);
}
fclose(fp);
exit(0);
}
static int daemonize()
{
pid_t pid = fork();
if (pid < 0)
{
perror("fork()");
return -1;
}
if (pid > 0)
return 0;
else if (pid == 0)
{
int fd = open("/dev/null", O_RDWR);
if (fd < 0)
{
perror("open()");
return -1;
}
dup2(fd, 0);
dup2(fd, 1);
dup2(fd, 2);
if (fd != 2)
close(fd);
setsid();
chdir("/");
umask(0);
return 0;
}
}
系统日志
每个人都有权限写日志,但是每个人写法都不一致,所以有个名为syslogd的服务。每个人通过一个函数接口将要写的日志提交给这个服务,然后由这个服务来写日志。所以实际只有syslogd这个服务有权利写日志。
以下三个函数就是一套系统日志写入接口:
void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);