《趣谈 Linux 操作系统》操作系统综述——小记随笔
你可以把 Linux 内核当成一家软件外包公司的老板
操作系统其实就像一个软件外包公司,其内核就相当于这家外包公司的老板。所以接下来的整个课程中,请你将自己的角色切换成这家软件外包公司的老板,设身处地地去理解操作系统是如何协调各种资源,帮客户做成事情的。
趣谈操作系统组成,类比至外包软件公司
操作系统全貌概览
快速上手几个 Linux 命令
用户与密码
# 修改密码 passwd # 添加用户 useradd # 查询用户、用户组 cat /etc/passwd cat /etc/group
浏览文件
# ls -l drwxr-xr-x 6 root root 4096 Oct 20 2017 apt -rw-r--r-- 1 root root 211 Oct 20 2017 hosts
-
其中第一个字段的第一个字符是文件类型。如果是“-”,表示普通文件;如果是 d,就表示目录。
-
第一个字段剩下的 9 个字符是模式,其实就是权限位(access permission bits)。3 个一组,每一组 rwx 表示“读(read)”“写(write)”“执行(execute)”。如果是字母,就说明有这个权限;如果是横线,就是没有这个权限。这三组分别表示文件所属的用户权限、文件所属的组权限以及其他用户的权限。例如,上面的例子中,-rw-r–r-- 就可以翻译为,这是一个普通文件,对于所属用户,可读可写不能执行;对于所属的组,仅仅可读;对于其他用户,也是仅仅可读。如果想改变权限,可以使用命令 chmod 711 hosts。
-
第二个字段是硬链接(hard link)数目。
-
第三个字段是所属用户,
-
第四个字段是所属组。
-
第五个字段是文件的大小
-
第六个字段是文件被修改的日期
-
最后是文件名。
你可以通过命令chown改变所属用户,chgrp改变所属组。
安装软件
# CentOS ## 安装命令 rpm -i jdk-XXX_linux-x64_bin.rpm ## 查看安装软件列表 rpm -qa ## 删除软件 rpm -e ## 软件管家 yum yum install java-11-openjdk.x86_64 yum erase java-11-openjdk.x86_64 ## yum 统一服务端 配置路径: /etc/yum.repos.d/CentOS-Base.repo [base] name=CentOS-$releasever - Base - 163.com baseurl=http://mirrors.163.com/centos/$releasever/os/$basearch/ gpgcheck=1 gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7 # Ubuntu ## 安装命令 dpkg -i jdk-XXX_linux-x64_bin.deb ## 查看安装软件列表 dpkg -l ## 删除软件 dpkg -r ## 软件管家 apt-get apt-get install openjdk-9-jdk apt-get purge openjdk-9-jdk。 ## apt 服务端 配置文件在/etc/apt/sources.list里。 deb http://mirrors.163.com/ubuntu/ xenial main restricted universe multiverse deb http://mirrors.163.com/ubuntu/ xenial-security main restricted universe multiverse deb http://mirrors.163.com/ubuntu/ xenial-updates main restricted universe multiverse deb http://mirrors.163.com/ubuntu/ xenial-proposed main restricted universe multiverse deb http://mirrors.163.com/ubuntu/ xenial-backports main restricted universe multiverse
其实无论下载、安装,还是通过软件管家安装,都是把可执行文件下载下来,然后再配置文件配置下。
Linux 配置环境变量
临时生效
export JAVA_HOME=/root/jdk-XXX_linux-x64 export PATH=$JAVA_HOME/bin:$PATH
永久生效,在用户的 .bashrc 文件中加上这两行
运行程序
- Linux 执行程序最常用的一种方式,通过 shell 在交互命令行里面运行。即
./filename
- 后台运行
nohup command >out.file 2>&1 &
这个时候,我们往往使用nohup命令。这个命令的意思是 no hang up(不挂起),也就是说,当前交互命令行退出的时候,程序还要在。
这里面,“1”表示文件描述符 1,表示标准输出,“2”表示文件描述符 2,意思是标准错误输出,“2>&1”表示标准输出和错误输出合并了。合并到哪里去呢?到 out.file 里。
- 程序运行的第三种方式,以服务的方式运行。
例如在 Ubuntu 中,我们可以通过 apt-get install mysql-server 的方式安装 MySQL,然后通过命令systemctl start mysql启动 MySQL,通过systemctl enable mysql设置开机启动。之所以成为服务并且能够开机启动,是因为在 /lib/systemd/system 目录下会创建一个 XXX.service 的配置文件,里面定义了如何启动、如何关闭。
在 CentOS 里有些特殊,MySQL 被 Oracle 收购后,因为担心授权问题,改为使用 MariaDB,它是 MySQL 的一个分支。通过命令yum install mariadb-server mariadb进行安装,命令systemctl start mariadb启动,命令systemctl enable mariadb设置开机启动。同理,会在 /usr/lib/systemd/system 目录下,创建一个 XXX.service 的配置文件,从而成为一个服务。
停止进程
ps -ef |grep 关键字 |awk '{print $2}'|xargs kill -9
学会几个系统调用
立项服务与进程管理
创建进程调用叫 fork,因为实现上是 fork 一个老的进程来创建新进程。老的叫父进程,新的子进程。
调用 fork 时候,子进程把父进程完完整整 copy 了一份,包括数据 + 代码,所以如果没有特殊处理,那父子进程就会按照一样代码执行下去了。
所以 fork 函数会根据场景提供返回值
- 如果当前是子进程,返回0;
- 如果当前是父进程,会返回进程号;
代码需要判定返回值,如果是 0 执行系统嗲用 execve 执行另外一段程序代码。否则按照原样执行。
对于操作系统也一样,启动的时候先创建一个所有用户进程的“祖宗进程”。
有时候,父进程要关心子进程的运行情况,这毕竟是自己身上掉下来的肉。有个系统调用waitpid,父进程可以调用它,将子进程的进程号作为参数传给它,这样父进程就知道子进程运行完了没有,成功与否。
会议室管理与内存管理
每个进程都有自己的内存,互相之间不干扰,有独立的进程内存空间。进程内存有啥内容
- 代码段
- 数据段,可能是栈空间,也可能是堆空间
分配内存时是按需分配,需要时才分配
- 系统调用 brk:当分配的内存数量比较小的时候,使用 brk,会和原来的堆的数据连在一起,这就像多分配两三个工位,在原来的区域旁边搬两把椅子就行了。
- 当分配的内存数量比较大的时候,使用 mmap,会重新划分一块区域,也就是说,当办公空间需要太多的时候,索性来个一整块。
档案库管理与文件管理
对于文件的操作,下面6个系统调用最重要
- 对于已经有的文件,可以使用open打开这个文件,close关闭这个文件;
- 对于没有的文件,可以使用creat创建文件;
- 打开文件以后,可以使用lseek跳到文件的某个位置;
- 可以对文件的内容进行读写,读的系统调用是read,写是write。
Linux 系统一切皆文件
- 启动一个进程,需要一个程序文件,这是一个二进制文件。
- 启动的时候,要加载一些配置文件,例如 yml、properties 等,这是文本文件;启动之后会打印一些日志,如果写到硬盘上,也是文本文件。
- 但是如果我想把日志打印到交互控制台上,在命令行上唰唰地打印出来,这其实也是一个文件,是标准输出 stdout 文件。
- 这个进程的输出可以作为另一个进程的输入,这种方式称为管道,管道也是一个文件。
- 进程可以通过网络和其他进程进行通信,建立的 Socket,也是一个文件。
- 进程需要访问外部设备,设备也是一个文件。
- 文件都被存储在文件夹里面,其实文件夹也是一个文件。
- 进程运行起来,要想看到进程运行的情况,会在 /proc 下面有对应的进程号,还是一系列文件。
每个文件,Linux 都会分配一个文件描述符(File Descriptor),这是一个整数。有了这个文件描述符,我们就可以使用系统调用,查看或者干预进程运行的方方面面。
所以说,文件操作是贯穿始终的,这也是“一切皆文件”的优势,就是统一了操作的入口,提供了极大的便利。
项目异常处理与信号处理
当项目遇到异常情况,例如项目中断,做到一半不做了。这时候就需要发送一个信号(Signal)给项目组。经常遇到的信号有以下几种:
- 在执行一个程序的时候,在键盘输入“CTRL+C”,这就是中断的信号,正在执行的命令就会中止退出;* 如果非法访问内存,例如你跑到别人的会议室,可能会看到不该看的东西;
- 硬件故障,设备出了问题,当然要通知项目组;
- 用户进程通过kill函数,将一个用户信号发送给另一个进程。
当项目组收到信号的时候,项目组需要决定如何处理这些异常情况。
对于一些不严重的信号,可以忽略,该干啥干啥,但是像 SIGKILL(用于终止一个进程的信号)和 SIGSTOP(用于中止一个进程的信号)是不能忽略的,可以执行对于该信号的默认动作。每种信号都定义了默认的动作,例如硬件故障,默认终止;也可以提供信号处理函数,可以通过sigaction系统调用,注册一个信号处理函数。
项目组间沟通与进程间通信
内存消息队列
首先就是发个消息,不需要一段很长的数据,这种方式称为消息队列(Message Queue)。由于一个公司内的多个项目组沟通时,这个消息队列是在内核里的,我们可以通过msgget创建一个新的队列,msgsnd将消息发送到消息队列,而消息接收方可以使用msgrcv从队列中取消息。
共享内存
当两个项目组需要交互的信息比较大的时候,可以使用共享内存的方式,也即两个项目组共享一个会议室(这样数据就不需要拷贝来拷贝去)。大家都到这个会议室来,就可以完成沟通了。这时候,我们可以通过shmget创建一个共享内存块,通过shmat将共享内存映射到自己的内存空间,然后就可以读写了。
但是,两个项目组共同访问一个会议室里的数据,就会存在“竞争”的问题。如果大家同时修改同一块数据咋办?这就需要有一种方式,让不同的人能够排他地访问,这就是信号量的机制 Semaphore。
公司间沟通与网络通信
不同机器的通过网络相互通信,要遵循相同的网络协议,也即 TCP/IP 网络协议栈。
。Linux 内核里有对于网络协议栈的实现。如何暴露出服务给项目组使用呢?网络服务是通过套接字 Socket 来提供服务的。
我们可以通过 Socket 系统调用建立一个 Socket。Socket 也是一个文件,也有一个文件描述符,也可以通过读写函数进行通信。
中介与 Glibc
如果你做过开发,你会觉得刚才讲的和平时咱们调用的函数不太一样。这是因为,平时你并没有直接使用系统调用。虽然咱们的办事大厅已经很方便了,但是为了对用户更友好,我们还可以使用中介 Glibc,有事情找它就行,它会转换成为系统调用,帮你调用。Glibc 是 Linux 下使用的开源的标准 C 库,它是 GNU 发布的 libc 库。Glibc 为程序员提供丰富的 API,除了例如字符串处理、数学运算等用户态服务之外,最重要的是封装了操作系统提供的系统服务,即系统调用的封装。
进程:公司接这么多项目,如何管?
写代码:用系统调用创建进程
yum -y groupinstall "Development Tools"
#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 (); } }
#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; }
在这里,我们创建的子程序运行了一个最最简单的命令 ls。
进行编译:程序的二进制格式
在 Linux 下面,二进制的程序也要有严格的格式,这个格式我们称为 ELF(Executeable and Linkable Format,可执行与可链接格式)
gcc -c -fPIC process.c gcc -c -fPIC createprocess.c
ELF 的第一种类型,可重定位文件(Relocatable File)
在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o 文件,这就是 ELF 的第一种类型,可重定位文件(Relocatable File)。
ELF 文件的头是用于描述整个文件的。这个文件格式在内核中有定义,分别为 struct elf32_hdr 和 struct elf64_hdr。
接下来我们来看一个一个的 section,我们也叫节。
- .text:放编译好的二进制可执行代码
- .data:已经初始化好的全局变量
- .rodata:只读数据,例如字符串常量、const 的变量
- .bss:未初始化全局变量,运行时会置 0
- .symtab:符号表,记录的则是函数和变量
- .strtab:字符串表、字符串常量和变量名
这些节的元数据信息也需要有一个地方保存,就是最后的节头部表(Section Header Table)。在这个表里面,每一个 section 都有一项,在代码里面也有定义 struct elf32_shdr 和 struct elf64_shdr。在 ELF 的头里面,有描述这个文件的节头部表的位置,有多少个表项等等信息。
多个文件互相调用,各个文件内的代码偏移会冲突,需要重定位。.rel.text, .rel.data 就与重定位有关。
- 在 rel.text 里面标注,这个函数create_process是需要重定位的。
要想让 create_process 这个函数作为库文件被重用,不能以.o 的形式存在,而是要形成库文件,最简单的类型是静态链接库.a 文件(Archives),仅仅将一系列对象文件(.o)归档为一个文件,使用命令 ar 创建。
ar cr libstaticprocess.a process.o
虽然这里 libstaticprocess.a 里面只有一个.o,但是实际情况可以有多个.o。当有程序要使用这个静态连接库的时候,会将.o 文件提取出来,链接到程序中。
gcc -o staticcreateprocess createprocess.o -L. -lstaticprocess
在这个命令里,-L 表示在当前目录下找.a 文件,-lstaticprocess 会自动补全文件名,比如加前缀 lib,后缀.a,变成 libstaticprocess.a,找到这个.a 文件后,将里面的 process.o 取出来,和 createprocess.o 做一个链接,形成二进制执行文件 staticcreateprocess。
这个链接的过程,重定位就起作用了,原来 createprocess.o 里面调用了 create_process 函数,但是不能确定位置,现在将 process.o 合并了进来,就知道位置了。
ELF 的第二种类型,可执行文件
这个格式和.o 文件大致相似,还是分成一个个的 section,并且被节头表描述。只不过这些 section 是多个.o 文件合并过的。但是这个时候,这个文件已经是马上就可以加载到内存里面执行的文件了,因而这些 section 被分成了需要加载到内存里面的代码段、数据段和不需要加载到内存里面的部分,将小的 section 合成了大的段 segment,
并且在最前面加一个段头表(Segment Header Table)。在代码里面的定义为 struct elf32_phdr 和 struct elf64_phdr,这里面除了有对于段的描述之外,最重要的是 p_vaddr,这个是这个段加载到内存的虚拟地址。
在 ELF 头里面,有一项 e_entry,也是个虚拟地址,是这个程序运行的入口。
# export LD_LIBRARY_PATH=. # ./dynamiccreateprocess # total 40 -rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo ......
ELF 的第三种类型,共享对象文件(Shared Object)。
静态链接库一旦链接进去,代码和变量的 section 都合并了,因而程序运行的时候,就不依赖于这个库是否存在。但是这样有一个缺点,就是相同的代码段,如果被多个程序使用的话,在内存里面就有多份,而且一旦静态链接库更新了,如果二进制执行文件不重新编译,也不随着更新。
因而就出现了另一种,动态链接库(Shared Libraries),不仅仅是一组对象文件的简单归档,而是多个对象文件的重新组合,可被多个程序共享。
gcc -shared -fPIC -o libdynamicprocess.so process.o
当一个动态链接库被链接到一个程序文件中的时候,最后的程序文件并不包括动态链接库中的代码,而仅仅包括对动态链接库的引用,并且不保存动态链接库的全路径,仅仅保存动态链接库的名称。
gcc -o dynamiccreateprocess createprocess.o -L. -ldynamicprocess
当运行这个程序的时候,首先寻找动态链接库,然后加载它。默认情况下,系统在 /lib 和 /usr/lib 文件夹下寻找动态链接库。如果找不到就会报错,我们可以设定 LD_LIBRARY_PATH 环境变量,程序运行时会在此环境变量指定的文件夹下寻找动态链接库。
# export LD_LIBRARY_PATH=. # ./dynamiccreateprocess # total 40 -rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo ......
基于动态链接库创建出来的二进制文件格式还是 ELF,但是稍有不同。首先,多了一个.interp 的 Segment,这里面是 ld-linux.so,这是动态链接器,也就是说,运行时的链接动作都是它做的。
另外,ELF 文件中还多了两个 section,一个是.plt,过程链接表(Procedure Linkage Table,PLT),一个是.got.plt,全局偏移量表(Global Offset Table,GOT)。
dynamiccreateprocess 这个程序要调用 libdynamicprocess.so 里的 create_process 函数。由于是运行时才去找,编译的时候,压根不知道这个函数在哪里,所以就在 PLT 里面建立一项 PLT[x]。这一项也是一些代码,有点像一个本地的代理,在二进制程序里面,不直接调用 create_process 函数,而是调用 PLT[x]里面的代理代码,这个代理代码会在运行的时候找真正的 create_process 函数。
去哪里找代理代码呢?这就用到了 GOT,这里面也会为 create_process 函数创建一项 GOT[y]。这一项是运行时 create_process 函数在内存中真正的地址。
如果这个地址在 dynamiccreateprocess 调用 PLT[x]里面的代理代码,代理代码调用 GOT 表中对应项 GOT[y],调用的就是加载到内存中的 libdynamicprocess.so 里面的 create_process 函数了。
但是 GOT 怎么知道的呢?对于 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 这个格式,这个时候它还是个程序,那怎么把这个文件加载到内存里面呢?在内核中,有这样一个数据结构,用来定义加载二进制文件的方法。
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 文件格式,有对应的实现。
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 是不是你很熟悉?没错,我们加载内核镜像的时候,用的也是这种格式。
还记得当时是谁调用的 load_elf_binary 函数吗?具体是这样的:do_execve->do_execveat_common->exec_binprm->search_binary_handler。
那 do_execve 又是被谁调用的呢?我们看下面的代码。
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { return do_execve(getname(filename), argv, envp); }
学过了系统调用一节,你会发现,原理是 exec 这个系统调用最终调用的 load_elf_binary。
exec 比较特殊,它是一组函数:
- 包含 p 的函数(execvp, execlp)会在 PATH 路径下面寻找程序;
- 不包含 p 的函数需要输入程序的全路径;
- 包含 v 的函数(execv, execvp, execve)以数组的形式接收参数;
- 包含 l 的函数(execl, execlp, execle)以列表的形式接收参数;
- 包含 e 的函数(execve, execle)以数组的形式接收环境变量。
在上面 process.c 的代码中,我们创建 ls 进程,也是通过 exec。
进程树
既然所有的进程都是从父进程 fork 过来的,那总归有一个祖宗进程,这就是咱们系统启动的 init 进程。
在解析 Linux 的启动过程的时候,1 号进程是 /sbin/init。如果在 centOS 7 里面,我们 ls 一下,可以看到,这个进程是被软链接到 systemd 的。
# ps -ef [root@deployer ~]# ps -ef UID PID PPID C STIME TTY TIME CMD root 1 0 0 2018 ? 00:00:29 /usr/lib/systemd/systemd --system --deserialize 21 root 2 0 0 2018 ? 00:00:00 [kthreadd] root 3 2 0 2018 ? 00:00:00 [ksoftirqd/0] root 5 2 0 2018 ? 00:00:00 [kworker/0:0H] root 9 2 0 2018 ? 00:00:40 [rcu_sched] ...... root 337 2 0 2018 ? 00:00:01 [kworker/3:1H] root 380 1 0 2018 ? 00:00:00 /usr/lib/systemd/systemd-udevd root 415 1 0 2018 ? 00:00:01 /sbin/auditd root 498 1 0 2018 ? 00:00:03 /usr/lib/systemd/systemd-logind ...... root 852 1 0 2018 ? 00:06:25 /usr/sbin/rsyslogd -n root 2580 1 0 2018 ? 00:00:00 /usr/sbin/sshd -D root 29058 2 0 Jan03 ? 00:00:01 [kworker/1:2] root 29672 2 0 Jan04 ? 00:00:09 [kworker/2:1] root 30467 1 0 Jan06 ? 00:00:00 /usr/sbin/crond -n root 31574 2 0 Jan08 ? 00:00:01 [kworker/u128:2] ...... root 32792 2580 0 Jan10 ? 00:00:00 sshd: root@pts/0 root 32794 32792 0 Jan10 pts/0 00:00:00 -bash root 32901 32794 0 00:01 pts/0 00:00:00 ps -ef
命令查看当前系统启动的进程,我们会发现有三类进程。
你会发现,PID 1 的进程就是我们的 init 进程 systemd,PID 2 的进程是内核线程 kthreadd,这两个我们在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。
接下来进程号依次增大,但是你会看所有带中括号的内核态的进程,祖先都是 2 号进程。而用户态的进程,祖先都是 1 号进程。tty 那一列,是问号的,说明不是前台启动的,一般都是后台的服务。
pts 的父进程是 sshd,bash 的父进程是 pts,ps -ef 这个命令的父进程是 bash。这样整个链条都比较清晰了。
总结时刻
线程:如何让复杂的项目并行执行?
为什么要有线程?
对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,它会根据项目执行计划书,一行一行执行下去。进程要比线程管的宽多了,除了执行指令之外,内存、文件系统等等都要它来管。
所以,进程相当于一个项目,而线程就是为了完成项目需求,而建立的一个个开发任务。默认情况下,你可以建一个大的任务,就是完成某某功能,然后交给一个人让它从头做到尾,这就是主线程。但是有时候,你发现任务是可以拆解的,如果相关性没有非常大前后关联关系,就可以并行执行。
使用进程实现并行执行的问题也有两个。
- 第一,创建进程占用资源太多;
- 第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享。
在 Linux 中,有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的,例如你输入了一个字符,不可能五分钟再显示出来;而有些任务是可以默默执行的,例如将本机的数据同步到服务器上去,这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理,以保证互不耽误。
如何创建线程?
#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.flv"}; 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("creating 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); }
开始编译。多线程程序要依赖于 libpthread.so。
gcc download.c -lpthread
这里我们画一张图总结一下,一个普通线程的创建和运行过程。
线程的数据
我们把线程访问的数据细分成三类:
- 第一类是线程栈上的本地数据,
比如函数执行过程中的局部变量。前面我们说过,函数的调用会使用栈的模型,这在线程里面是一样的。只不过每个线程都有自己的栈空间。
栈的大小可以通过命令 ulimit -a 查看,默认情况下线程栈大小为 8192(8MB)。我们可以使用命令 ulimit -s 修改。
-
第二类数据就是在整个进程里共享的全局数据。
例如全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。如果同一个全局变量,两个线程一起修改,那肯定会有问题,有可能把数据改的面目全非。这就需要有一种机制来保护他们,比如你先用我再用。 -
这就是第三类数据,线程私有数据(Thread Specific Data)
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))
可以看到,创建一个 key,伴随着一个析构函数。key 一旦被创建,所有线程都可以访问它,但各线程可根据自己的需要往 key 中填入不同的值,这就相当于提供了一个同名而不同值的全局变量。
int pthread_setspecific(pthread_key_t key, const void *value) void *pthread_getspecific(pthread_key_t key)
而等到线程退出的时候,就会调用析构函数释放 value。
数据的保护
我们先来看一种方式,Mutex,全称 Mutual Exclusion,中文叫互斥。顾名思义,有你没我,有我没你。它的模式就是在共享数据访问的时候,去申请加把锁,谁先拿到锁,谁就拿到了访问权限,其他人就只好在门外等着,等这个人访问结束,把锁打开,其他人再去争夺,还是遵循谁先拿到谁访问。
#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 pthread_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); }
我们来编译一下。
gcc mutex.c -lpthread
使用 Mutex,首先要使用 pthread_mutex_init 函数初始化这个 mutex,初始化后,就可以用它来保护共享变量了。pthread_mutex_lock() 就是去抢那把锁的函数,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,就被阻塞在那里等待。
如果不想被阻塞,可以使用 pthread_mutex_trylock 去抢那把锁,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,不会被阻塞,而是返回一个错误码。
当共享数据访问结束了,别忘了使用 pthread_mutex_unlock 释放锁,让给其他人使用,最终调用 pthread_mutex_destroy 销毁掉这把锁。
在使用 Mutex 的时候,有个问题是如果使用 pthread_mutex_lock(),那就需要一直在那里等着。如果是 pthread_mutex_trylock(),就可以不用等着,去干点儿别的,但是我怎么知道什么时候回来再试一下,是不是轮到我了呢?能不能在轮到我的时候,通知我一下呢?这其实就是条件变量,也就是说如果没事儿,就让大家歇着,有事儿了就去通知,别让人家没事儿就来问问,浪费大家的时间。
但是当它接到了通知,来操作共享资源的时候,还是需要抢互斥锁,因为可能很多人都受到了通知,都来访问了,所以条件变量和互斥锁是配合使用的。
#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 pthread_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 coders!\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_unlock(&g_task_lock); pthread_mutex_destroy(&g_task_lock); pthread_cond_destroy(&g_task_cv); pthread_exit(NULL); }
总结时刻
写多线程的程序是有套路的,我这里用一张图进行总结。你需要记住的是,创建线程的套路、mutex 使用的套路、条件变量使用的套路。
进程数据结构(上):项目多了就需要项目管理系统
在 Linux 里面,无论是进程,还是线程,到了内核里面,我们统一都叫任务(Task),由一个统一的结构 task_struct 进行管理。
设想一下,Linux 的任务管理都应该干些啥?首先,所有执行的项目应该有个项目列表吧,所以 Linux 内核也应该先弄一个链表,将所有的 task_struct 串起来。
struct list_head tasks;
任务 ID
pid_t pid; pid_t tgid; struct task_struct *group_leader;
pid 是 process id,tgid 是 thread group ID。
任何一个进程,如果只有主线程,那 pid 是自己,tgid 是自己,group_leader 指向的还是自己。
但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的 pid,tgid 就是进程的主线程的 pid,group_leader 指向的就是进程的主线程。
信号处理
这里既然提到了下发指令的问题,我就顺便提一下 task_struct 里面关于信号处理的字段。
/* Signal handlers: */ 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 这三个变量的作用。
上面我说了下发信号的时候,需要区分进程和线程。从这里我们其实也能看出一些端倪。task_struct 里面有一个 struct sigpending pending。如果我们进入 struct signal_struct *signal 去看的话,还有一个 struct sigpending shared_pending。它们一个是本任务的,一个是线程组共享的。
关于信号,你暂时了解到这里就够用了,后面我们会有单独的章节进行解读。
任务状态
在 task_struct 里面,涉及任务状态的是下面这几个变量:
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */ int exit_state; unsigned int flags;
state(状态)可以取的值定义在 include/linux/sched.h 头文件中。
/* Used in tsk->state: */ #define TASK_RUNNING 0 #define TASK_INTERRUPTIBLE 1 #define TASK_UNINTERRUPTIBLE 2 #define __TASK_STOPPED 4 #define __TASK_TRACED 8 /* Used in tsk->exit_state: */ #define EXIT_DEAD 16 #define EXIT_ZOMBIE 32 #define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD) /* Used in tsk->state again: */ #define TASK_DEAD 64 #define TASK_WAKEKILL 128 #define TASK_WAKING 256 #define TASK_PARKED 512 #define TASK_NOLOAD 1024 #define TASK_NEW 2048 #define TASK_STATE_MAX 4096
从定义的数值很容易看出来,state 是通过 bitset 的方式设置的,也就是说,当前是什么状态,哪一位就置一。
TASK_RUNNING 并不是说进程正在运行,而是表示进程在时刻准备运行的状态。当处于这个状态的进程获得时间片的时候,就是在运行中;如果没有获得时间片,就说明它被其他进程抢占了,在等待再次分配时间片。
在运行中的进程,一旦要进行一些 I/O 操作,需要等待 I/O 完毕,这个时候会释放 CPU,进入睡眠状态。在 Linux 中,有两种睡眠状态。
- 一种是 TASK_INTERRUPTIBLE,可中断的睡眠状态。这是一种浅睡眠的状态,也就是说,虽然在睡眠,等待 I/O 完成,但是这个时候一个信号来的时候,进程还是要被唤醒。只不过唤醒后,不是继续刚才的操作,而是进行信号处理。当然程序员可以根据自己的意愿,来写信号处理函数,例如收到某些信号,就放弃等待这个 I/O 操作完成,直接退出;或者收到某些信息,继续等待。
- 另一种睡眠是 TASK_UNINTERRUPTIBLE,不可中断的睡眠状态。这是一种深度睡眠状态,不可被信号唤醒,只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成,这个时候,谁也叫不醒这个进程了。你可能会说,我 kill 它呢?别忘了,kill 本身也是一个信号,既然这个状态不可被信号唤醒,kill 信号也被忽略了。除非重启电脑,没有其他办法。
因此,这其实是一个比较危险的事情,除非程序员极其有把握,不然还是不要设置成 TASK_UNINTERRUPTIBLE。于是,我们就有了一种新的进程睡眠状态,TASK_KILLABLE,可以终止的新睡眠状态。进程处于这种状态中,它的运行原理类似 TASK_UNINTERRUPTIBLE,只不过可以响应致命信号。
TASK_STOPPED 是在进程接收到 SIGSTOP、SIGTTIN、SIGTSTP 或者 SIGTTOU 信号之后进入该状态。
TASK_TRACED 表示进程被 debugger 等进程监视,进程执行被调试程序所停止。当一个进程被另外的进程所监视,每一个信号都会让进程进入该状态。
一旦一个进程要结束,先进入的是 EXIT_ZOMBIE 状态,但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息,此时进程就成了僵尸进程。
EXIT_DEAD 是进程的最终状态。
EXIT_ZOMBIE 和 EXIT_DEAD 也可以用于 exit_state。
上面的进程状态和进程的运行、调度有关系,还有其他的一些状态,我们称为标志。放在 flags 字段中,这些字段都被定义成为宏,以 PF 开头。我这里举几个例子。
#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 去掉。
进程调度
进程的状态切换往往涉及调度,下面这些字段都是用于调度的。为了让你理解 task_struct 进程管理的全貌,我先在这里列一下
//是否在运行队列上 int on_rq; //优先级 int prio; int static_prio; int normal_prio; unsigned int rt_priority; //调度器类 const struct sched_class *sched_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;
总结时刻
进程数据结构(中):项目多了就需要项目管理系统
运行统计信息
那如何才能知道这些员工的工作情况呢?在进程的运行过程中,会有一些统计量,具体你可以看下面的列表。这里面有进程在用户态和内核态消耗的时间、上下文切换的次数等等。
u64 utime;//用户态消耗的CPU时间 u64 stime;//内核态消耗的CPU时间 unsigned long nvcsw;//自愿(voluntary)上下文切换计数 unsigned long nivcsw;//非自愿(involuntary)上下文切换计数 u64 start_time;//进程启动时间,不包含睡眠时间 u64 real_start_time;//进程启动时间,包含睡眠时间
进程亲缘关系
从我们之前讲的创建进程的过程,可以看出,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。
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 用于把当前进程插入到兄弟链表中。
进程权限
在 Linux 里面,对于进程权限的定义如下:
/* 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。事实上,所谓的权限,就是我能操纵谁,谁能操纵我。“谁能操作我”,很显然,这个时候我就是被操作的对象,就是 Objective,那个想操作我的就是 Subjective。“我能操作谁”,这个时候我就是 Subjective,那个要被我操作的就是 Objectvie。
real_cred 就是说明谁能操作我这个进程,而 cred 就是说明我这个进程能够操作谁。
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,注释是 real user/group id。一般情况下,谁启动的进程,就是谁的 ID。但是权限审核的时候,往往不比较这两个,也就是说不大起作用。
第二个是 euid 和 egid,注释是 effective user/group id。一看这个名字,就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候,其实就是在比较这个用户和组是否有权限。
第三个是 fsuid 和 fsgid,也就是 filesystem user/group id。这个是对文件操作会审核的权限。
一般说来,fsuid、euid,和 uid 是一样的,fsgid、egid,和 gid 也是一样的。因为谁启动的进程,就应该审核启动的用户到底有没有这个权限。
但是也有特殊的情况。
特殊情况一
例如,用户 A 想玩一个游戏,这个游戏的程序是用户 B 安装的。游戏这个程序文件的权限为 rwxr–r--。A 是没有权限运行这个程序的,所以用户 B 要给用户 A 权限才行。用户 B 说没问题,都是朋友嘛,于是用户 B 就给这个程序设定了所有的用户都能执行的权限 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 就不是用户 A 了,因为看到了 set-user-id 标识,就改为文件的所有者的 ID,也就是说,euid 和 fsuid 都改成用户 B 了,这样就能够将通关结果保存下来。
在 Linux 里面,一个进程可以随时通过 setuid 设置用户 ID,所以,游戏程序的用户 B 的 ID 还会保存在一个地方,这就是 suid 和 sgid,也就是 saved uid 和 save gid。这样就可以很方便地使用 setuid,通过设置 uid 或者 suid 来改变权限。
特殊情况二
除了以用户和用户组控制权限,Linux 还有另一个机制就是 capabilities。
原来控制进程的权限,要么是高权限的 root 用户,要么是一般权限的普通用户,这时候的问题是,root 用户权限太大,而普通用户权限太小。有时候一个普通用户想做一点高权限的事情,必须给他整个 root 的权限。这个太不安全了。
于是,我们引入新的机制 capabilities,用位图表示权限,在 capability.h 可以找到定义的权限。我这里列举几个。
#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 表示当可执行文件的扩展属性设置了 inheritable 位时,调用 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。
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是 mm_struct。
文件与文件系统
每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。
/* Filesystem information: */ struct fs_struct *fs; /* Open file information: */ struct files_struct *files;
本文作者:Blue Mountain
本文链接:https://www.cnblogs.com/BlueMountain-HaggenDazs/p/18057047
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 实操Deepseek接入个人知识库
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· 【.NET】调用本地 Deepseek 模型
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)