进程

写代码:用系统调用创建进程

process.c函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

extern int create_process(char* program, char** arg_list);

int create_process(char* program, char** arg_list)
{
pid_t child_pid;
child_pid = fork();
if (child_pid != 0)
return child_pid;
else {
execvp(program, arg_list);
abort();
}
}

createprocess.c函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// createprocess.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

extern int create_process(char* program, char** arg_list);

int main() {
char* arg_list[] = {
"ls",
"-l",
"/etc/yum.repos.d/",
NULL
};
create_process("ls", arg_list);
return 0;
}

进行编译:程序的二进制格式

  • Linux下面,二进制的程序有严格的格式,即为ELF(Executeable and Linkable Format,可执行与可链接格式)
  • 根据编译结果的不同,分为不同的格式
ELF格式 文件后缀
可重定位文件 Relocatable File .o
可执行文件 Executeable File 无后缀
共享对象文件 Share Object .so

Eagle_2hEvuGXtsh

使用gcc进行编译

1
2
gcc -c -fPIC process.c
gcc -c -fPIC createprocess.c
  • 在编译的时候,会先做预处理工作
    • 如将头文件嵌入到正文中,将定义的宏展开
  • 然后是真正的编译过程
  • 最后编译成为.o文件,这就是ELF的第一种类型,可重定位文件(Relocatable File)
    • 这里会生成process.o和createprocess.o两个文件

可重定位文件

文件格式如下:

Eagle_5jVXbzmkIQ

ELF文件的头是用于描述整个文件的,文件格式在内核中有定义,分别为strcut elf32_hdr和strcut elf64_hdr,ELF文件一个一个的section,叫做

  • .text:放编译好的二进制可执行文件
  • .data:已经初始化好的全局变量
  • .rodata:只读数据,例如字符串常量、const的变量
  • .bss:未初始化全局变量,运行时会置0
  • .symtab:符号表,记录的则是函数和变量
  • .strtab:字符串表、字符串常量和变量名

注意点:为什么这里只有全局变量?

局部变量是放在栈里面的,是程序运行过程中随时分配空间,随时释放的,现在讨论的是二进制文件,还没有启动,只需讨论在哪里保存全局变量

  • 这些节的元数据信息需要有一个地方保存,就是最后的节头部表(Section Header Table)
  • 在表里,每一个section都有一项,在代码里面也有定义的struct elf32_shdr和struct elf64_shdr
  • 在ELF的头里面,有描述这个文件的节头部表的位置,有多少个表项等信息

可重定位的意义

编译好的代码和变量,将来加载到内存里面的时候,都是要加载到一定位置的,但是现在还是一个.o文件,不是一个可直接运行的程序,只是部分代码片段,将来被谁调用,在哪里调用都不清楚,所以.o里面的位置是不确定的,但是必须是可重新定位的,因为它将来是要做函数库的

  • .rel.text和.rel.data与重定位有关

  • 要想让create_process函数作为库文件被重用,不能以.o的形式存在,而是要形成库文件,最简单的类型是静态链接库.a文件(Archives),仅仅将一系列对象文件(.o)归档为一个文件,使用命令ar创建

1
ar cr libstaticprocess.a process.o

这里可以有多个.o,当有程序要使用这个静态链接库的时候,会将.o文件提取出来,链接到程序中

1
gcc -o staticcreateprocess createprocess.o -L. -lstaticprocess
  • 在这个命令中,-L表示在当前目录下找.a文件,-lstaticprocess会自动补全文件名,比如加前缀lib,后缀.a,变成libstaticprocess.a

  • 找到这个.a文件后,将里面的process.o取出来,和createprocess.o做一个链接,形成二进制执行文件staticcreateprocess

  • 这个链接的过程,重定位就起作用了,createprocess.o将process.o合并进来,就知道create_process函数的位置了

可执行文件

形成的二进制文件叫可执行文件,是ELF的第二种格式

文件格式如下:

Eagle_U4RyvvQMXV

  • 与.o文件大致相似,分成一个个的section,并且被节头表描述

  • 这些section是多个.o文件合并过的

  • 这个时候,这个文件已经是马上就可以加载到内存里面执行的文件了,因此这些section被分成了几个部分

    • 需要加载到内存里面的代码段、数据段
    • 不需要加载到内存里面的部分
  • 将小的section合成了大的段segment,并且在最前面加一个段表头(Segment Header Table)

  • 在代码里面的定义为struct elf32_phdr和struct elf64_phdr,这里面除了有对于段的描述之外,最重要的是p_vaddr,这个是这段加载到内存的虚拟地址

  • 在ELF头里面,有一项e_entry,也是个虚拟地址,是这个程序运行的入口

  • 运行该程序后,会执行ls命令

1
2
3
4
5
6
7
8
9
10
[root@localhost ~]# ./staticcreateprocess
[root@localhost ~]# total 40
-rw-r--r--. 1 root root 1664 Oct 23 2020 CentOS-Base.repo
-rw-r--r--. 1 root root 1309 Oct 23 2020 CentOS-CR.repo
-rw-r--r--. 1 root root 649 Oct 23 2020 CentOS-Debuginfo.repo
-rw-r--r--. 1 root root 314 Oct 23 2020 CentOS-fasttrack.repo
-rw-r--r--. 1 root root 630 Oct 23 2020 CentOS-Media.repo
-rw-r--r--. 1 root root 1331 Oct 23 2020 CentOS-Sources.repo
-rw-r--r--. 1 root root 8515 Oct 23 2020 CentOS-Vault.repo
-rw-r--r--. 1 root root 616 Oct 23 2020 CentOS-x86_64-kernel.repo

共享对象文件

  • 静态链接库一旦链接进去,代码和变量的section都合并了,因而程序运行的时候,就不依赖这个库是否存在

    • 但有一个缺点,就是相同代码段,被多个程序使用,在内存里面就有多份
    • 而且一旦静态链接库更新,如二进制执行文件不重新编译,也不会随着更新
  • 因此,出现了动态链接库(Shared Lirary)

    • 不仅仅是一组对象文件的简单归档,而是多个对象文件的重新组合,可被多个程序共享
    1
    gcc -shared -fPIC -o libdynamicprocesss.so process.o

    注意点:

    当一个动态链接库被链接到一个程序文件中时,最后的程序文件并不包括动态链接库中的代码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存链接库的名称

  • 创建可执行文件

    1
    gcc -o dynamiccreateprocess createprocess.o -L. -ldynamicprocess
  • 当运行这个程序的时候,首先寻找动态链接库,然后加载它

    • 默认情况下,系统在/lib和/usr/lib文件夹下寻找动态链接库,找不到就会报错
    • 可以设定LD_LIBRARY_PATH环境变量,程序运行时会在此环境变量指定的文件夹下寻找动态链接库
1
2
3
4
5
6
7
8
9
10
11
12
[root@localhost ~]# export LD_LIBRARY_PATH=.
[root@localhost ~]# ./dynamiccreateprocess
[root@localhost ~]# total 40
-rw-r--r--. 1 root root 1664 Oct 23 2020 CentOS-Base.repo
-rw-r--r--. 1 root root 1309 Oct 23 2020 CentOS-CR.repo
-rw-r--r--. 1 root root 649 Oct 23 2020 CentOS-Debuginfo.repo
-rw-r--r--. 1 root root 314 Oct 23 2020 CentOS-fasttrack.repo
-rw-r--r--. 1 root root 630 Oct 23 2020 CentOS-Media.repo
-rw-r--r--. 1 root root 1331 Oct 23 2020 CentOS-Sources.repo
-rw-r--r--. 1 root root 8515 Oct 23 2020 CentOS-Vault.repo
-rw-r--r--. 1 root root 616 Oct 23 2020 CentOS-x86_64-kernel.repo

  • 基于动态链接库创建出来的二进制文件格式还是ELF,但是稍有不同
    • 首先,多了一个.interp的Segment,这里面是Id-linux.so,这是动态链接器,运行时的链接动作都是它做的
    • 另外,ELF文件中还多了两个section
      • 一个是.plt,过程链接表(Proced Linkage Table,PLT)
      • 一个是.got.plt,全局偏移量表(Global Offset Table,GOT)
  • 程序运行的时候,它们是如何将so文件动态链接到进程空间的?
    • dynamiccreateprocess这个程序要调用libdynamicprocess.so里的create_process函数
    • 由于是运行时才去找,编译的时候不知道函数在哪里,所以就在PLT里面建立一项PLT[x]
      • 这一项是一些代码,有点像一个本地的代理,在二进制程序里面,不直接调用create_process函数,而是调用PTL[x]里面的代理代码,这个代理代码会在运行的时候找真正的create_process函数
    • 使用GOT来找代理代码,这里面也会为create_process函数创建一项GOT[y]
      • 这一项是运行时create_process函数在内存中真正的地址
      • 如果这个地址在,dynamiccreateprocess调用PLT[x]里面的代理代码,代理代码调用GOT表中对应项GOT[y],调用的就是加载到内存中的libdynamicprocess.so里面的create_process函数了
    • 对于create_process函数,GOT一开始就会创建一项GOT[y],但是这里面没有真正的地址,因为它也不知道,但它又回调PLT,告诉它,你里面的代码代理来找我要create_process函数的真实地址,但我不知道。
    • PLT这个时候会转而调用PLT[0],即第一项,PLT[0]转而调用GOT[2],这里面是ld-linux.so的入口函数
      • 这个函数会找到加载到内存中的libdynamicprocess.so里面的create_process函数的地址,然后把这个地址放在GOT[y]里面
      • 下次,PLT[x]的代理函数就能够直接调用了

运行程序为进程

我们现在知道了ELF这个格式,但这个时候它还是个程序,如何将这个文件加载到内存里面

  • 在内核中,有一个数据结构被用来定义加载二进制文件的方法
1
2
3
4
5
6
7
8
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
int (*core_dump)(struct coredump_params *cprm);
unsigned long min_coredump; /* minimal dump size */
}__randomize_layout;
  • 对于ELF文件格式,有对应的实现
1
2
3
4
5
6
7
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};
  • 其中,load_elf_binary我们在加载内核镜像的时候,用的也是这种格式。

  • 当前的具体调用过程为:do_execve -> do_execveat_common -> exec_binprm -> search_binary_hander.

  • 调用do_execve的是SYSCALL_DEFINE3函数,原理是exec这个系统调用最终调用的load_elf_binary

  • exec比较特殊,它是一组函数:
    • 包含p的函数(execvp,execlp)会在PATH路径下面寻找程序
    • 不包含p的函数需要输入程序的全路径
    • 包含v的函数(execv,execvp,execve)以数组的形式接受参数
    • 包含l的函数(execl, execlp, execle)以列表的形式接受参数
    • 包含e的函数(execve, execle)以数组的形式接受环境变量

进程树

所有的进程都是从父进程fork过来的,总有一个祖宗进程,就是系统启动的init进程

  • 解析Linux的启动过程中,1号进程是/sbin/init

    • 如果在CentOS 7里面,我们ls查看,可以看到,这个进程是被软链接到systemd的
    1
    2
    [root@localhost ~]# ls -la  /sbin/init
    lrwxrwxrwx. 1 root root 22 Apr 10 03:53 /sbin/init -> ../lib/systemd/systemd

  • 系统启动之后,init进程会启动很多的daemon进程,为系统运行提供服务,然后启动getty,让用户登录,登录后运行shell,用户启动的进程都是通过shell运行的,从而形成了一棵进程树
  • 通过ps -ef命令查看当前系统启动的进程
    • PID 1的进程是init进程systemd
    • PID 2的进程是内核线程kthreadd
    • 用户态的不带中括号,内核态的带中括号
    • 进程号依次增大,但是会看到所有带中括号的内核态的进程,祖先都是2号进程,用户态的进程的祖先都是1号进程
    • TTY一列,是问号的,说明不是前台启动的,一般都是后台的服务
    • pts的父进程是sshd,bash的父进程是pts,ps -ef的父进程是bash
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[root@localhost ~]# ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 Apr10 ? 00:00:02 /usr/lib/systemd/systemd --switched-root --system --d
root 2 0 0 Apr10 ? 00:00:00 [kthreadd]
root 4 2 0 Apr10 ? 00:00:00 [kworker/0:0H]
root 6 2 0 Apr10 ? 00:00:00 [ksoftirqd/0]
root 7 2 0 Apr10 ? 00:00:00 [migration/0]
root 8 2 0 Apr10 ? 00:00:00 [rcu_bh]
root 9 2 0 Apr10 ? 00:00:00 [rcu_sched]
root 10 2 0 Apr10 ? 00:00:00 [lru-add-drain]
root 11 2 0 Apr10 ? 00:00:00 [watchdog/0]
root 12 2 0 Apr10 ? 00:00:00 [watchdog/1]
root 13 2 0 Apr10 ? 00:00:00 [migration/1]
root 14 2 0 Apr10 ? 00:00:00 [ksoftirqd/1]
root 16 2 0 Apr10 ? 00:00:00 [kworker/1:0H]
.....
root 922 792 0 Apr10 ? 00:00:00 /sbin/dhclient -d -q -sf /usr/libexec/nm-dhcp-helper
root 1116 1 0 Apr10 ? 00:00:00 /usr/sbin/sshd -D
.....
root 2091 1116 0 Apr10 ? 00:00:00 sshd: root@pts/0
root 2095 1116 0 Apr10 ? 00:00:00 sshd: root@notty
root 2101 2091 0 Apr10 pts/0 00:00:00 -bash
.....
root 5532 712 0 00:03 ? 00:00:00 sleep 60
root 5533 2101 0 00:03 pts/0 00:00:00 ps -ef

小结

Eagle_hCgqpyDwCp

  • 首先通过图右边的文件编译过程,生成so文件和可执行文件,放在硬盘上
  • 左边的用户态的进程A执行fork,创建进程B
  • 在进程B的处理逻辑中,执行exec系列的系统调用
    • 这个系统调用会通过load_elf_binary方法,将刚才生成的可执行文件,加载到进程B的内存中执行

线程

为什么需要线程

  • 对于任何一个进程,即使我们没有主动去创建线程,进程也是默认有一个主线程的
  • 线程是负责执行二进制指令的
  • 进程要比线程管的多,除了执行指令之外,内存、文件系统等都要它来管

进程相当于一个项目,线程就是为了完成需求,建立的一个个开发任务

  • 使用进程实现并发执行有两个主要问题
    • 第一,创建进程占用资源太多
    • 第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享

创建进程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUM_OF_TASKS 5
// 线程函数
void *downloadfile(void *filename)
{
printf("I am downloading the file %s!\n", (char *)filename);
sleep(10);
long downloadtime = rand()%100;
printf("I finish downloading the file within %d minutes!\n", downloadtime);
pthread_exit((void *)downloadtime);
}

int main(int argc, char *argv[])
{
char files[NUM_OF_TASKS][20] = {"file1.avi", "file2.rmvb", "file3.mp4", "file4.wmv", "file5.md"};
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
int downloadtime;
// 声明线程对象,设置线程属性
pthread_attr_t thread_attr;
pthread_attr_init(&thread_attr);
pthread_attr_setdetachstate(&thread_attr, PTHREAD_CREATE_JOINABLE);

for(t = 0; t < NUM_OF_TASKS; t++) {
printf("createing thread %d, please help me to download %s\n", t, files[t]);
// 创建线程
rc = pthread_create(&threads[t], &thread_attr, downloadfile, (void *)files[t]);
if(rc) {
printf("ERROR; return code from pthread_create() is %d\n", rc);
exit(-1);
}
}
// 销毁线程属性
pthread_attr_destroy(&thread_attr);
for(t = 0; t < NUM_OF_TASKS; t++) {
// 等待线程结束
pthread_join(threads[t], (void**)&downloadtime);
printf("Thread %d downloads the file %s in %d minutes.\n", t, files[t], downloadtime);
}
pthread_exit(NULL);
}
  • 一个运行中的线程可以调用pthread_exit退出线程

    • 该函数可以传入一个参数转换为(void *)类型,是线程退出的返回值
  • 主线程里

    • 列了5个文件名,声明了一个数组,里面有5个pthread_t类型的线程对象

    • 声明了一个线程属性pthread_attr_t,通过pthread_attr_init初始化这个属性,并且设置属性PTHTREAD_CREATE_JOINABLE,表示将来主线程等待这个线程的结束,并获取退出时的状态

    • 接下来是一个循环,对于每一个文件和每一个线程,可以调用pthread_create创建线程。

      • 一共有4个参数,第一个参数是线程对象,第二个参数是线程的属性,第三个参数是线程运行函数,第四个参数是线程运行函数的参数
      • 主线程就是通过第4个参数,将自己的任务派给子线程
  • 任务分配完成后,每个线程下载一个文件,主线程需要做的事情就是等待这些子任务完成

  • 当一个线程退出的时候,就会发送信号给其他所有同进程的线程

  • 使用pthread_join获取这个线程退出的返回值,线程的返回值通过pthread_join传给主线程,这样子线程就将自己下载文件所消耗的时间,告诉给主线程

多线程程序要依赖于libpthread.so

1
gcc download.c -lpthread

编译后执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[root@localhost thread]# ./a.out
createing thread 0, please help me to download file1.avi
createing thread 1, please help me to download file2.rmvb
I am downloading the file file1.avi!
createing thread 2, please help me to download file3.mp4
createing thread 3, please help me to download file4.wmv
I am downloading the file file2.rmvb!
createing thread 4, please help me to download file5.md
I am downloading the file file3.mp4!
I am downloading the file file4.wmv!
I am downloading the file file5.md!
I finish downloading the file within 83 minutes!
Thread 0 downloads the file file1.avi in 83 minutes.
I finish downloading the file within 86 minutes!
I finish downloading the file within 77 minutes!
I finish downloading the file within 15 minutes!
I finish downloading the file within 93 minutes!
Thread 1 downloads the file file2.rmvb in 15 minutes.
Thread 2 downloads the file file3.mp4 in 77 minutes.
Thread 3 downloads the file file4.wmv in 86 minutes.
Thread 4 downloads the file file5.md in 93 minutes.

小结

Eagle_PSYRytJZwx

线程的数据

  • 线程访问的数据细分为3类

    • 线程栈上的本地数据,如函数执行过程中的局部变量

      • 栈的大小可以通过命令ulimit -a查看,默认情况下线程栈大小为8192(8MB)
        • 可通过命令ulimit -s修改
      • 对于线程栈,可通过函数pthread_attr_setstacksize,来修改线程栈的大小
      1
      int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
      • 主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间
      • 为避免线程之间的栈空间踩踏,线程栈之间还会有小块区域用来隔离保护各自的栈空间,一旦另一个线程踏入隔离区,会引发段错误
    • 在整个进程里共享的全局数据,如全局变量

      • 如果同一个全局变量,两个线程一起修改,肯定会有问题,需要有一种机制保护他们
    • 线程私有数据(Thread Specific Data)

      • 可通过以下函数创建
      1
      int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))
      • 创建一个key,伴随着一个析构函数

      • 一旦key被创建,所有线程都可访问它,但各线程可根据自身需要往key中填入不同的值,相当于提供了一个同名而不同值的全局变量

      • 可通过下面的函数设置key对应的value

      1
      int pthread_setspecific(pthread_key_t key, const void *value)
      • 获取key对应的value
      1
      void *pthread_getspecific(pthread_key_t key)

数据保护

互斥(Mutex,Mutual Exclusion)

  • 在共享数据访问的时候,申请加把锁,谁拿到锁,谁就有访问权限,遵循谁先拿到谁访问
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUM_OF_TASKS 5

int money_of_tom = 100;
int money_of_jerry = 100;

pthread_mutex_t g_money_lock;

void *transfer(void *notused) {
pthread_t tid = pthread_self();
printf("Thread %u is transfering money!\n", (unsigned int)tid);

pthread_mutex_lock(&g_money_lock);

sleep(rand()%10);
money_of_tom += 10;
sleep(rand()%10);
money_of_jerry -= 10;

pthread_mutex_unlock(&g_money_lock);

printf("Thread %u finish transfering money!\n", (unsigned int)tid);
pthread_exit((void *)0);
}

int main(int argc, char *argv[]) {
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;

pthread_mutex_init(&g_money_lock, NULL);

for(t = 0; t < NUM_OF_TASKS; t++) {
rc = pthread_create(&threads[t], NULL, transfer, NULL);
if(rc) {
printf("ERROR; return code from pthred_create() is %d\n", rc);
exit(-1);
}
}

for(t = 0; t < 100; t++) {
pthread_mutex_lock(&g_money_lock);
printf("money_of_tom + money_of_jerry = %d\n", money_of_tom+money_of_jerry);
pthread_mutex_unlock(&g_money_lock);
}
pthread_mutex_destroy(&g_money_lock);
pthread_exit(NULL);
}
  • 如果没有加上mutex,那么中间的状态会很不正确,会出现超过200的情况

  • 加上mutex后,可以保证两者之和永远都是200

  • 使用Mutex,首先要使用pthread_mutex_init函数初始化这个mutex,初始化后,用它保护共享变量

  • pthread_mutex_lock()是抢那个锁的函数,抢到了,可以执行下一行程序,对共享变量进行访问;没抢到,被阻塞在那里瞪大

  • 如不想被阻塞,可以使用pthread_mutex_trylock去抢锁

  • 共享数据访问结束后,使用pthread_mutex_unlock释放锁,让给其他人使用

  • 最后调用pthread_mutex_destroy销毁锁

小结

  • 如果使用pthread_mutex_lock(),需要一直在那里等待
  • 如果是pthread_mutex_trylock(),就不用等待,可以去干点别的
    • 需要条件变量来进行通知,条件变量和互斥锁是配合使用的

条件变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUM_OF_TASKS 3
#define MAX_TASK_QUEUE 11

char tasklist[MAX_TASK_QUEUE] = "ABCDEFGHIJ";
int head = 0;
int tail = 0;

int quit = 0;

pthread_mutex_t g_task_lock;
pthread_cond_t g_task_cv;

void *coder(void *notused) {
pthread_t tid = pthread_self();

while(!quit) {
pthread_mutex_lock(&g_task_lock);
// 当前没有任务可以做了
while(tail == head) {
if(quit) {
pthread_mutex_unlock(&g_task_lock);
pthread_exit((void *)0);
}
printf("No task now! Thread %u is waiting!\n", (unsigned int)tid);
// 这里会解锁,然后等待条件变量
pthread_cond_wait(&g_task_cv, &g_task_lock);
printf("Have task now! Thread %u is grabing the task!\n", (unsigned int)tid);
}
char task = tasklist[head++];
pthread_mutex_unlock(&g_task_lock);
printf("Thread %u has a task %c now!\n", (unsigned int)tid, task);
sleep(5);
printf("Thread %u finish the task %c!\n", (unsigned int)tid, task);
}
pthread_exit((void *)0);
}

int main(int argc, char *argv[]) {
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
// 初始化
pthread_mutex_init(&g_task_lock, NULL);
pthread_cond_init(&g_task_cv, NULL);

for(t = 0; t < NUM_OF_TASKS; t++) {
// 创建线程
rc = pthread_create(&threads[t], NULL, coder, NULL);
if(rc) {
printf("ERROR; return code from pthred_create() is %d\n", rc);
exit(-1);
}
}

sleep(5);

for(t = 1; t <= 4; t++) {
pthread_mutex_lock(&g_task_lock);
// 分配任务
tail += t;
printf("I am Boss, I assigned %d tasks, I notify all coder!\n", t);
// 操作了共享变量后,通知所有线程
pthread_cond_broadcast(&g_task_cv);
pthread_mutex_unlock(&g_task_lock);
sleep(20);
}

pthread_mutex_lock(&g_task_lock);
quit = 1;
pthread_cond_broadcast(&g_task_cv);
pthread_mutex_lock(&g_task_lock);

pthread_mutex_destroy(&g_task_lock);
pthread_cond_destroy(&g_task_cv);
pthread_exit(NULL);
}
  • 有10个任务,每个任务一个字符
  • 有两个变量head和tail,表示当前分配的工作从哪里开始,到哪里结束
    • 如果head等于tail,则当前的工作分配完毕
    • 如果tail加N,就是新分配了N个工作

总结

Eagle_K3NMoooTyb

进程数据结构

有的线程只有一个线程,有的进程有多个线程,它们都需要由内核分配CPU来干活。但是CPU总共就那么几个,应该怎么管理,怎么进行调度呢

  • 在Linux里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构task_struct进行管理

  • 首先,Linux内核应该先弄一个链表,将所有的task_struct串起来
1
struct list_head tasks

任务

任务ID

  • 每一个任务都应该有一个ID,作为任务的唯一标识

  • task_struct里面涉及任务ID的,有以下几个

1
2
3
pid_t pid;
pid_t tgid;
struct task_struct *group_leader;

为什么会有多个标识呢?是因为上面的进程和线程到了内核这里,统一变成了任务,带来了两个问题

  • 任务展示
    • 如ps命令可以展出所有的进程,到了内核,按照任务列表展出的话,所有的线程也会平摊开来
  • 给任务下发指令
    • 如kill命令可以给进程发信号,通知进程退出。如果只发给了其中一个线程,我们就不能只退出这个线程,而是应该退出整个进程,当然有时候,希望只给某个线程发信号

所以,在内核中,进程和线程虽然都是任务,但是应加以区分。

其中,pid是process id,tgid是thread group ID

  • 任何一个进程,如果只有主线程,那pid是自己,tgid是自己group_leader指向的还是自己
  • 如果一个进程创建了其他线程,那么,线程有自己的pidtgid就是进程的主线程的pidgroup_leader指向的就是进程的主线程

信号处理

task_struct里面关于信号处理的字段

1
2
3
4
5
6
7
8
9
struct signal_struct    *signal;
struct sighand_struct *sighand;
sigset_t blocked;
sigset_t real_blocked;
sigset_t saved_sigmask;
struct sigpending pending;
unsigned long sas_ss_sp;
size_t sas_ss_size;
unsigned int sas__ss_flags;
  • 定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)
    • 处理结果可以是忽略,可以使结束进程等等
  • 信号处理函数默认使用用户态的函数栈,也可以开辟新的栈专门用于信号处理,这就是sas_ss_xxx这三个变量的作用
  • 我们有一个struct sigpending pending,进入struct signal_struct *signal里面看的话,还有一个struct sigpending shared _pending,它们一个是本任务的,一个是线程组共享的

任务状态

在task_struct里面,涉及任务状态的是下面几个变量

1
2
3
volatile long state;
int exit_state;
unsigned int flags;

state(状态)可以取的值定义在include/linux/sched.h头文件中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* Used in tsk->state: */
#define TASK_RUNNING 0x00000000
#define TASK_INTERRUPTIBLE 0x00000001
#define TASK_UNINTERRUPTIBLE 0x00000002
#define __TASK_STOPPED 0x00000004
#define __TASK_TRACED 0x00000008
/* Used in tsk->exit_state: */
#define EXIT_DEAD 0x00000010
#define EXIT_ZOMBIE 0x00000020
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_PARKED 0x00000040
#define TASK_DEAD 0x00000080
#define TASK_WAKEKILL 0x00000100
#define TASK_WAKING 0x00000200
#define TASK_NOLOAD 0x00000400
#define TASK_NEW 0x00000800
#define TASK_RTLOCK_WAIT 0x00001000
#define TASK_FREEZABLE 0x00002000
#define __TASK_FREEZABLE_UNSAFE (0x00004000 * IS_ENABLED(CONFIG_LOCKDEP))
#define TASK_FROZEN 0x00008000
#define TASK_STATE_MAX 0x00010000

从定义的数值很容易看出,flags是通过bitset的方式设置的,也就是说,当前时什么状态,哪一位就至1(二进制)

  • TASK_RUNNING并不是说进程正在运行,而是表示进程在时刻准备运行的状态

    • 当处于这个状态的进程获得时间片的时候,就是在运行中
    • 如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片
  • 在运行的过程,一旦要进行一些I/O操作,需要等待I/O完毕,这个时候会释放CPU,进入睡眠状态

    • Linux中有两种睡眠状态

      • 一种是 TASK_INTERRUPTIBLE,可中断的睡眠状态。是一种浅睡眠的状态,也就是说,虽然在睡眠,等待I/O完成,但是这个时候一个信号来的时候,进程还是要被唤醒
        • 但是唤醒后,不是继续刚才的操作,而是进行信号处理
        • 当然,程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个I/O操作完成,直接退出;也可收到某些信息,继续等待
      • 另一种是 TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。是一种深度睡眠状态,不可被信号唤醒,只能死等I/O操作完成。一旦I/O操作因为特殊原因不能完成,谁也叫不醒这个进程了。
        • kill本身也是一个信号,kill信号也会被忽略,除非重启电脑
    • 有了一种新的进程睡眠状态,TASK_KILLABLE,可以终止的新睡眠状态,进程处于这种状态中,运行原理类型与不可中断的睡眠状态,只不过可以响应致命信号

      • TASK_WAKEKILL用于在接受到致命信号时唤醒进程,而TASK_KILLABLE相当于这两位都设置了
      1
      #define TASK_KILLABLE   (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
    • TASK_STOPPED是在进程接收到SIGSTOP、SIGTTIN、SIGTSTP或者SIGTTOU信号之后进入该状态

    • TASK_TRACED表示进程被debugger等进程监视,进程执行被调试程序所停止

      • 当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态
  • 一个进程结束的时候,先进入的是EXIT_ZOMBIE状态,但是此时它的父进程还没有使用wait()等系统调用来获知它的终止信息,此时进程就成了僵尸进程

    • EXIT_DEAD是进程的最终状态
    • EXIT_ZOMBIE和EXIT_DEAD也可以用于exit_state
  • 上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,称为标志,放在flags字段中,这些字段被定义称为,以PF开头,下面是几个例子

1
2
3
#define PF_EXITING     0x00000004
#define PF_VCPU 0x00000010
#define PF_FORKNOEXEC 0x00000040

PF_EXITING表示正在退出,当有这个flag的时候,在函数find_alive_thread中,找活着的线程,遇到有这个flag的,就直接跳过

PF_VCPU表示进程运行在虚拟CPU上。在函数 account_system_time 中,统计进程的系统运行时间,如果有这个 flag,就调用 account_guest_time,按照客户机的时间进行统计

PF_FORKNOEXEC表示fork完了,还没有exec。在 _do_fork 函数里面调用copy_process,这个时候把 flag 设置为 PF_FORKNOEXEC。当 exec 中调用了load_elf_binary 的时候,又把这个 flag 去掉。

进程调度

进程的状态切换往往涉及调度。

下面是一些关于调度的字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 是否在运行队列上
int on_rq;
// 优先级
int prio;
int static_prio;
int normal_prio;
unsigned int rt_priority;
// 调度器类
const struct sched_class *sced_class;
// 调度实体
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
// 调度策略
unsigned int policy;
// 可以使用哪些CPU
int nr_cpus_allowed;
cpumask_t cpus_allowed;
struct sched_info sched_info;

小结

运行统计信息

在进程的运行过程中,会有一些统计量,具体可看下面列表,有进程在用户态和内核态消耗的时间、上下文切换的次数等等

1
2
3
4
5
6
u64				utime; // 用户态消耗的CPU时间
u64 stime; // 内核态消耗的CPU时间
unsigned long nvcsw; // 自愿(voluntary)上下文切换计数
unsigned long nivcsw; // 非自愿(involuntary)上下文切换计数
u64 start_time; // 进程启动时间,不包含睡眠时间
u64 real_start_time; // 进程启动时间,包含睡眠时间

进程亲缘关系

任何一个进程都有父进程,所以,整个进程其实就是一棵进程树,而拥有同一父进程的所有进程都具有兄弟关系

1
2
3
4
struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list *
  • parent 指向其父进程。当它终止时,必须向它的父进程发送信号。
  • children 表示链表的头部。链表中的所有元素都是它的子进程。
  • sibling 用于把当前进程插入到兄弟链表中。

  • 通常情况下,real_parent和parent是一样的,但也有另外的情况存在
    • 例如,bash创建一个进程,那进程的 parent 和 real_parent 就都是 bash
    • 如果在 bash 上使用 GDB来 debug 一个进程,这个时候 GDB 是 real_parent,bash 是这个进程的 parent

进程权限

在Linux里面,对于进程权限的定义如下:

1
2
3
4
/* Objective and real subjective task credentials(COW): */
const struct cred __rcu *real_cred;
/* Effective (overridable) subjective task credentials(COW): */
const struct cred __rcu *cred;
  • Objective是被操作的对象,而Subjective是进行操作的对象
    • 操作,就是一个对象对另一个对象进行某些动作
    • 当动作要实施的时候,就要审核权限,当两边的权限匹配上,才可以实施动作
    • 其中,read_cred是说明被操作的进程,cred是进行操作的进程
  • cred的定义如下,大部分是用户和用户所属的用户组信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct cred {
......
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
......
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
......
} __randomize_layout;
  • 第一个是uid和gid,注释是read user/group id。
    • 一般情况下,谁启动的进程,就是谁的ID
    • 但是权限审核的时候,往往不比较这两个,说明不大起作用
  • 第二个是euid和egid,注释时effective user/group id
    • 是起作用的ID
    • 当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限
  • 第三个是fsuid和fsgid,也就是filesystem user/group id
    • 这个是对文件操作会审核的权限

一般来说,fsuid、euid和uid是一样的,fsgid、egid和gid也是一样的

因为谁启动的进程,就应该审核启动的用户到底有没有这个权限

特殊情况

以用户和用户组控制权限

  • 用户A想运行用户B安装的游戏程序(权限为rwxr--r--),但A是没有权限运行该程序的,因此B必须要给用户A权限才行,设定程序为所有用户都能执行(权限为rwxr-xr-x
  • 用户A便可以运行游戏程序,游戏运行时,游戏进程的uid、euid、fsuid都是用户A
  • 但是,用户A想保存通关数据的时候,发现游戏的玩家数据时保存在另一个文件里面的(权限为rw-------),只给用户B开了写入权限,而游戏进程的euid和fsuid都是用户A,写不进去
    • 可以使用chmod u+s program,给游戏程序设置set-user-ID的标识位,将游戏的权限变为rwsr-xr-x
    • 当用户A再启动游戏时,创建的进程uid是用户A,但是euid和fsuid是用户B,这样可以将游戏数据保存下来

在Linux里面,一个进程可以随时通过setuid设置用户ID,所以游戏程序的用户B的ID还会保存在一个地方,就是suid和sgid,这样就可以方便地使用setuid,通过设置uid或者suid来改变权限

capabilities机制
  • 控制进程的权限,要么是高权限的root用户,要么是一般权限的普通用户
    • 这时候的问题是,root用户权限太大,普通用户权限太小
    • 有时候普通用户向做一点高权限的事情,得给他整个root的权限,实在不安全
  • 因此引入新的机制capabilities,用位图来表示权限,可在capability.h找到定义的权限
1
2
3
4
5
6
7
8
9
10
11
#define CAP_CHOWN            0
#define CAP_KILL 5
#define CAP_NET_BIND_SERVICE 10
#define CAP_NET_RAW 13
#define CAP_SYS_MODULE 16
#define CAP_SYS_RAWIO 17
#define CAP_SYS_BOOT 22
#define CAP_SYS_TIME 25
#define CAP_AUDIT_READ 37
#define CAP_LAST_CAP CAP_AUDIT_READ
.....
  • 对于普通用户运行的进程,当有这个权限的时候,就能做相应的操作
  • cap_permitted:表示进程能够使用的权限,但真正起作用的是cap_effective
    • cap_permitted中可以包含cap_effective中没有的权限
    • 一个进程可以在必要的时候,放弃自己的某些权限,这样更加安全
  • cap_inheritable:表示当可执行文件的扩展属性设置了inferitable位时,调用exec执行该程序会继承调用者的inheritable集合,并将其加入到permitted集合
    • 但在非root用户下执行exec时,通常不会保留inheritable集合
    • 往往又是非root用户,才想保留权限,因此十分鸡肋
  • cap_bset:也就是capability bounding set,是系统中所有进程允许保留的权限
    • 如果这个集合中不存在某个权限,那么系统中的所有进程都没有这个权限
    • 即使以超级用户权限执行的进行,也是一样的
    • 这样可以有很多好处,例如,系统启动以后,将加载内核模块的权限去除,那所有进程都不能加载内核模块,这样即使这台机器被攻破,也做不了太多有害的事情
  • cap_ambient:为解决cap_inheritable状况而加入的,也就是非root用户进程使用exec执行一个程序的时候,保留权限的问题。
    • 当执行exec的时候,cap_ambient会被添加到cap_permitted中,同时设置到cap_effective中

内存管理

每个进程都有自己独立的虚拟内存空间,需要一个数据结构来表示,即mm_struct

ps. 后面内存管理具体讲述

1
2
struct mm_struct		*mm;
struct mm_struct *active_mm;

文件与文件系统

每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构

ps. 后面文件系统具体讲述

1
2
3
4
/* Filesystem information: */
struct fs_struct *fs;
/* Open file information: */
struct files_struct *files;

小结

在程序执行过程中,一旦调用到系统调用,就需要进入内核继续执行。如何将用户态的执行和内核态的执行串起来呢?

需要两个重要的成员变量

1
2
struct thread_info		thread_info;
void *stack;

用户态函数栈

在用户态中,程序的执行往往是一个函数调用另一个函数。

函数调用都是通过栈来进行的。

  • 在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构
    • 也就是说,上面是栈底,下面是栈顶
    • 入栈和出栈的操作都是从下面的栈顶开始的

函数栈

32位操作系统

在CPU里,ESP是栈顶指针寄存器