OSTEP:5 Process API

A Little Bit Process API

fork()

#include <stdio.h>    
#include <stdlib.h>
#include <unistd.h>

int
main(int argc, char *argv[])
{
int x=0;
printf("hello world (pid:%d)\n", (int)getpid());
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child (new process)
printf("hello, I am child (pid:%d) by (ppid:%d)---%p\n", (int) getpid(),(int)getppid(),&x);
} else {
// parent goes down this path (original process)
printf("hello, I am parent of %d (pid:%d)---%p\n",
rc, (int) getpid(),&x);
}
return 0;
}
grxer@grxer ~/D/s/O/o/cpu-api> ./p1
hello world (pid:5116)
hello, I am parent of 5117 (pid:5116)---0x7ffd842ab520
hello, I am child (pid:5117) by (ppid:1)---0x7ffd842ab520
grxer@grxer ~/D/s/O/o/cpu-api> ./p1
hello world (pid:5164)
hello, I am parent of 5165 (pid:5164)---0x7ffd2fc1d450
hello, I am child (pid:5165) by (ppid:5164)---0x7ffd2fc1d450

man 2 fork

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);

调用fork()的进程被称为父进程,fork()会创建一个子进程,子进程不会从 main()函数开始执行,而是直接从 fork()系统调用返回

子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用fork时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的PID。

fork的实质就是得到父进程在内核里描述进程信息的数据结构的副本,然后给一个新的pid

fork的子进程和父进程采用了一种叫做写时复制(copy-on-write )的技术,子进程直接复制了页表,他们刚开始映射到了同一块物理内存,刚开始物理地址和虚拟地址父子都相同,任何一个进程要对共享的页面“写操作”,内核会复制一个物理页面给这个进程使用,同时修改页表映射,copy on write同样存在于进程之间共享内存的场景

对于实际实现上可能会把原来可写页面标记为只读,并把页面引用计数加一,当有进程写入时会触发异常,操作系统根据引用计数来确定是COW,复制一份写入,并把页面引用计数减一

image-20230411222658013

RETURN VALUE
On success, the PID of the child process is returned in the parent, and
0 is returned in the child. On failure, -1 is returned in the parent,
no child process is created, and errno is set appropriately.

通过返回值可以进行不同处理

有趣的时我们上面两次结果的ppid是不同的,这是因为CPU调度程序(scheduler)决定了某个时刻哪个进程被执行,例如上一章的IO

image-20230411223805891

进程1(init进程)没有父进程,是所有进程的祖先进程,如果子进程的父进程在子进程结束之前终止了,那么子进程的父进程ID就会变为1,即init进程的PID。

wait

man 2 wait

进程结束后,内核并不是立即把它从系统中清除,被保持在一种已终止的状态中,直到被它的父进程回收(reaped)。被叫做僵死进程,如果父进程先结束了,虽然init进程会继承他们并回收,我们最好还是让父进程去做

#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid,int *stateusp,int options=0)
/*
如果在调用waitpid()函数时,当指定等待的子进程已经停止运行或结束了,则waitpid()会立即返回;但是如果子进程还没有停止运行或结束,则调用waitpid()函数的父进程则会被阻塞,暂停运行。
返回:如果成功,则为子进程的 PID, 如果 option WNOHANG, 则为 0, 如果其他错误,则为— 1,
arg1 pid>0 等待集合是一个单独的子进程 id=pid
pid =0 等待进程组号与目前进程相同的任何进程
pid=-1 等待集合就是由父进程所有的子进程组成的
pid< -1等待进程组号为pid绝对值的任何子进程
arg2 放上关于导致返回的子进程的状态信息,wait.h头文件定义了返回宏
arg3 • WNOHANG: 非阻塞等待进程,如果等待集合中的任何子进程都还没有终止,那么就立即返回(返回值为 0)。如果有子进程退出了,父进程调用waitpid函数返回子进程的PID,在等待子进程终止的同时,如果还想做些有用的丁作,这个选项会有用。
• WUNTRACED: 挂起调用进程的执行,直到等待集合中的一个进程变成巳终止或者被停止。返回的 PID 为导致返回的巳终止或被停止子进程的 PID 。 默认的行为是只返回己终止的子进程。当你想要检查己终止和被停止的子进程时,这个选项会有用。
• WCONTINUED: 挂起调用进程的执行,直到等待集合中一个正在运行的进程终止或等待集合中一个被停止的进程收到 SIGCONT 信号重新开始执行。
*/
pid_t wait(int *statusp);
waitpid的简单版本 相当于waitpid(- 1,*statusp, 0)
返回:如果成功,则为子进程的 PID, 如果出错,则为 -1
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int
main(int argc, char *argv[])
{
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
sleep(1);
} else {
// parent goes down this path (original process)
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
rc, wc, (int) getpid());
}
return 0;
}
grxer@grxer ~/D/s/O/o/cpu-api> ./p2
hello world (pid:5404)
hello, I am child (pid:5405)
hello, I am parent of 5405 (wc:5405) (pid:5404)

两个进程运行先后顺序还是取决于cpu调度

exec

man 3 exec

exec 函数族

#include <unistd.h>

extern char **environ;

int execl(const char *pathname, const char *arg, ...
/* (char *) NULL */);
int execlp(const char *file, 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 execve(const char *pathname, char *const argv[], char *const envp[])
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);

只有execve是真正意义上的系统调用,其它都是在此基础上经过包装的库函数,函数功能大同小异就是调用加载器根据要加载的ELF头表里的信息覆写调用程序
的代码段(以及静态数据),堆、栈及其他内存空间也会被重新初始化,并把参数传递,该函数调用成功不会返回

execve实际上也不会把磁盘里的程序拷贝到物理内存,而是把进程的页表映射到磁盘上,触发缺页后由处理程序再加载到物理内存供cpu 使用

被继承

  • 文件描述符
  • 信号处理程序
  • 用户和组 ID
  • 环境变量
  • 信号屏蔽字

当前进程中的任何线程或锁不会被继承,在新程序中,不会有任何父进程中的局部变量、栈和堆等内存信息。

int execve(const char *pathname, char *const argv[], char *const envp[])

  • filename:指向可执行文件名的用户空间指针。
  • argv:参数列表,指向用户空间的参数列表起始地址
  • envp:环境变量表,环境变量是一系列键值对,字符串类型
  • argv envp数组以null结尾

int main (int argc, char *argv [], char *envp [])

image-20230420112311806

/* myecho.c */

#include <stdio.h>
#include <stdlib.h>

int
main(int argc, char* argv[],char *envp[]) {
for (int j = 0; j < argc; j++)
printf("argv[%d]: %s\n", j, argv[j]);

for (size_t i = 0; envp[i]; i++) {
printf("%s\n", envp[i]);
}
exit(EXIT_SUCCESS);
}

/* execve.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int
main(int argc, char *argv[])
{
char *newargv[] = { NULL, "hello", "world", NULL };
char *newenviron[] = { "grxer=666" };

if (argc != 2) {
fprintf(stderr, "Usage: %s <file-to-exec>\n", argv[0]);
exit(EXIT_FAILURE);
}

newargv[0] = argv[1];

execve(argv[1], newargv, newenviron);
perror("execve"); /* execve() returns only on error */
exit(EXIT_FAILURE);
}
grxer@grxer ~/D/s/test> gcc  -g -o myecho myecho.c && gcc -g -o execve execve.c
grxer@grxer ~/D/s/test> ./execve ./myecho
argv[0]: ./myecho
argv[1]: hello
argv[2]: world
grxer=666
./myecho
hello
world

用奇怪的约定做一些神奇的事情

fork和exev看起来是非常奇怪的,为什么要把两个操作分开去创建新进程,而不是整合成一个api,其实这给了shell 在fork 之后 exec 之前运行代码的机会,来实现一些很有趣的事

比如说我们的输出重定向

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <assert.h>
#include <sys/wait.h>

int
main(int argc, char *argv[])
{
int rc = fork();
if (rc < 0) {
// fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// child: redirect standard output to a file
close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);

// now exec "wc"...
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p4.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
} else {
// parent goes down this path (original process)
int wc = wait(NULL);
assert(wc >= 0);
}
return 0;
}
grxer@grxer ~/D/s/O/o/cpu-api> ./p4
grxer@grxer ~/D/s/O/o/cpu-api> cat ./p4.output
34 114 918 p4.c

可以看到我们成功把输出重定向到文件

原理:

fork后子进程会继承父进程默认打开的标准输入、标准输出和标准错误输出0、1 和 2 ,默认情况下,这些文件描述符与终端设备关联,因此,当程序从标准输入读取数据时,数据会从终端输入;当程序向标准输出写入数据时,数据会输出到终端。

我们在exec前把标准输出文件描述符给关闭,然后再打开./p4.output时,UNIX 系统从 0 开始寻找可以使用的文件描述符,自然找到之前被关闭的标准输出文件描述符,给替换掉,实现重定向

strace看一下 -ff filename 将不同进程(子进程)产生的系统调用输出到filename.PID文件

grxer@grxer ~/D/s/O/o/cpu-api> strace -ff -o task ./p4
grxer@grxer ~/D/s/O/o/cpu-api> ls ./task*
./task.7615 ./task.7616

close(1) = 0
openat(AT_FDCWD, "./p4.output", O_WRONLY|O_CREAT|O_TRUNC, 0700) = 1
newfstatat(1, "", {st_mode=S_IFREG|0777, st_size=17, ...}, AT_EMPTY_PATH) = 0
write(1, " 34 114 918 p4.c\n", 17) = 17

The openat() system call operates in exactly the same way as open(2)