TLPI 笔记 #
第 3 章 系统编程概念 #
3.1 系统调用 #
系统调用与C语言函数调用不同,系统调用的开销要比普通C语言函数要高的多的,过程如下:
- 应用程序通过调用C语言函数库中的外壳(wrapper)函数,来发起系统调用。
- 外壳函数将参数复制到寄存器。
- 由于所有系统调用进入内核的方式相同,内核需要设法区分每个系统调用。 为此,外壳函数会将系统调用编号复制到一个特殊的 CPU 寄存器(%eax)中。
- 外壳函数执行一条中断机器指令(int 0x80),引发处理器从用户态切换到内核态,并执行系统中断0x80的中断矢量所指向的代码。
- 为响应中断0x80,内核会调用
system_call()
例程来处理这次中断。 返回至外壳函数,同时将处理器切换回用户态。 - 若系统调用服务例程的返回值表明调用有误,外壳函数会使用该值来设置全局变量 errno。 然后,外壳函数会返回到调用程序,并同时返回一个整型值,以表明系统调用是否成功。
3.4 错误处理 #
系统调用失败时,会将全局整型变量 errno
设置为一个正值,以标识具体的错误。
需要包含 <errno.h>
头文件。
必须先检查函数的返回值是否出错,然后再检查 errno
出错原因。
因为调用成功是不会把 errno
重置为 0 的,它可能是之前的调用失败造成的。
第 5 章 深入探究文件 I/O #
5.1 原子操作和竞争条件 #
所有系统调用都是以原子操作方式执行的。内核保证某系统调用中的所有步骤会作为独立操作,期间不会为其他进程或线程所中断。
比如向文件尾部追加数据,需要将文件偏移量的移动和数据写操作纳入一个原子操作,在open文件时加入 O_APPEND
标志保证这一点。
// Wrong
if (lseek(fd, 0, SEEK_END) == -1)
errExit();
if (write(fd, buf, len) != len) { ... }
5.4 文件描述符和打开文件之间的关系 #
两个不同的文件描述符,若指向同一打开文件句柄,将共享同一文件偏移量。 因此,如果通过其中一个文件描述符来修改文件偏移量(由调用 read()、write() 或 lseek() 所致),那么从另一文件描述符中也会观察到这一变化。 无论这两个文件描述符分属于不同进程,还是同属于一个进程,情况都是如此。
这种情况可能出现在:
- 在fork后父子进程中出现。
./myscript > result.log 2>&1
shell 通过复制文件描述符实现了标准错误的重定向操作,因此文件描述符 2 与文件描述符 1 指向同一个打开文件句柄。 可以通过调用dup()
和dup2()
来实现此功能。
第 6 章 进程 #
6.3 进程内存布局 #
x86-64 中内存结构,x86-32 的地址为 0x08048000 - 0xC0000000
+----------------------+
| Kernel memory | mapped into process virtual memory,
| | but not accessible to program
2^48 - 1 +----------------------+
| argv, environ |
+----------------------+
| User stack ↓ |
+----------------------+ <- %rsp (stack pointer)
| |
+----------------------+
| Memory-mapped region | Shared, file-backend
| for shared libraries | example: libc.so
+----------------------+
| |
+----------------------+ <- brk (program break)
| Heap ↑ |
+----------------------+
| .bss | Uninitialized data, read/write
+----------------------+
| .data | Initialized data, read/write
+----------------------+
| .init,.text,.rodata | Text (program code), read-only
0x00400000 +----------------------+
0 +----------------------+
每个进程所分配的内存由很多部分组成
- 文本段包含了进程运行的程序机器语言指令。 文本段具有只读属性,以防止进程通过错误指针意外修改自身指令。 因为多个进程可同时运行同一程序,所以又将文本段设为可共享,这样,一份程序代码的拷贝可以映射到所有这些进程的虚拟地址空间中。
- 初始化数据段包含显式初始化的全局变量和静态变量。 当程序加载到内存时,从可执行文件中读取这些变量的值。
- 未初始化数据段包含了未进行显式初始化的全局变量和静态变量。
程序启动之前,系统将本段内所有内存初始化为 0。
出于历史原因,此段常被称为 BSS 段,这源于老版本的汇编语言助记符
block started by symbol
。 将初始化的全局变量和静态变量与未经初始化的全局变量和静态变量分开存放, 其主要原因在于程序在磁盘上存储时,没有必要为未经初始化的变量分配存储空间。 相反,可执行文件只需记录未初始化数据段的位置及所需大小,直到运行时再由程序加载器来分配这一空间。 - 栈(stack)是一个动态增长和收缩的段,由栈帧(stack frames)组成。 系统会为每个当前调用的函数分配一个栈帧。栈帧中存储了函数的局部变量、实参和返回值。
- 堆(heap)是可在运行时动态进行内存分配的一块区域。堆顶端称作
program break
。
6.8 执行非局部跳转:setjmp() 和 longjmp() #
非局部跳转是指跳转的目标为当前执行函数之外的某个位置。
C语言的 goto 语句存在一个限制,即不能从当前函数跳转到另一函数。
setjump
和 longjmp
可以做到跨函数的goto,但要注意不能跳转到一个已经返回的函数,即不能跳转到一个已经不再栈中的函数。
第 7 章 内存分配 #
7.1 在堆上分配内存 #
分配或释放内存,本质就是命令内核改变进程的 program break
位置。
最初 program break
正好位于为未初始化数据段末尾之后。
在 program break
的位置抬升后,程序可以访问新分配区域内的任何内存地址,而此时物理内存页尚未分配。
内核会在进程首次试图访问这些虚拟内存地址时自动分配新的物理内存页。
两个系统调用
brk(void *end_data_segment)
:将program break设置为参数指定的位置sbrk(intptr_t increment)
:将program break在原有地址上增加参数指定的大小
一般使用C库函数 malloc
和 free
。
free
一般并不降低 program break
的位置,而是将这块内存添加到空闲内存列表中,供后续 malloc
使用。
被释放的内存块通常会位于堆的中间,而非堆的顶部,因而降低 porgram break
是不可能的。
如果 free
的是堆的顶部,那么是会使用 sbrk
降低 program break
位置的。
free
还会合并相邻的空闲内存块为更大的内存块。
calloc
:与malloc
不同的是会将已分配的内存初始化为0realloc
:调整之前分配的内存的大小
7.2 在堆栈上分配内存:alloca() #
void *alloca(size_t size)
在函数栈帧上分配内存,速度快于 malloc
,因为不需要维护空闲内存块列表。
并且内存是随栈帧的移除而自动释放的,即当调用 alloca
的函数返回之时。
第 9 章 进程凭证 #
- 实际用户 ID(real user ID)和实际组 ID(real group ID) 当前登陆系统的账户
- 有效用户 ID(effective user ID)和有效组 ID(effective group ID) 执行进程的用户ID,有效用户 ID 为 0(root 的用户 ID)的进程拥有超级用户的所有权限。 这样的进程又称为特权级进程(privileged process)。而某些系统调用只能由特权级进程执行。
Set-User-ID 会将进程的有效用户ID置为可执行文件的用户ID(属主),从而获得常规情况不具备的权限。 比如有个 check_password 程序
$ ll check_password
-rwxr-xr-x 1 tsing tsing 35112 8月 15 09:12 check_password
$ ./check_password
Username: tsing
ERROR: no permission to read shadow password file
# Set-User-ID 设置程序文件
# s 出现在用户的 x 权限上,"-rwsr-xr-x",普通用户可以临时获得特权
$ sudo chown root check_password
$ sudo chmod u+s check_password
$ ll check_password
-rwsr-xr-x 1 root tsing 35112 8月 15 09:12 check_password
$ ./check_password
Username: tsing
Password:
Successfully authenticated: UID=1000
举例:用户自己修改自己的密码,所有账号的密码都记录在 /etc/shadow 文件里。
一般用户对于 /usr/bin/passwd 的权限有执行权限,但不对 /etc/shadow 有权限。
用户执行 passwd
时,会“暂时”获得root权限,shadow可以被用户执行的passwd所修改。
第 12 章 系统和进程信息 #
12.1 /proc 文件系统 #
/proc
虚拟文件系统并不存储在磁盘上,而是由内核在进程访问此信息时动态创建而成。
/proc/{PID}/cmdline
可看到进程的启动命令/proc/{PID}/environ
可看到进程的环境变量/proc/{PID}/cwd
指向当前工作目录的符号链接/proc/{PID}/exe
指向正在执行文件的符号链接/proc/{PID}/fd
文件目录,包含指向由进程打开文件的符号链接/proc/{PID}/maps
内存映射/proc/{PID}/mem
进程虚拟内存/proc/{PID}/mounts
挂载点/proc/{PID}/status
各种信息/proc/{PID}/task/{TID}
进程中每个线程都包含一个子目录
第 13 章 文件 I/O 缓冲 #
13.1 文件 I/O 的内核缓冲:缓冲区高速缓存 #
read()
和 write()
系统调用在操作磁盘文件时不会直接发起磁盘访问,而是仅仅在用户空间缓冲区与内核缓冲区高速缓存之间复制数据。
例如,write(fd, "abc", 3);
将 3 个字节的数据从用户空间内存传递到内核空间的缓冲区中,write()
随即返回。
在后续某个时刻,内核会将其缓冲区中的数据写入(刷新至)磁盘。
因此,可以说系统调用与磁盘操作并不同步。
通过采用大块空间缓冲数据,以及执行更少的系统调用,可以极大地提高 I/O 性能。
13.4 I/O 缓冲小结 #
- 首先是通过 stdio 库将用户数据传递到 stdio 缓冲区,该缓冲区位于用户态内存区。
也可显式强制刷新 stdio 缓冲区,
fflush()
- 当缓冲区填满时,stdio 库会调用
write()
系统调用,将数据传递到内核高速缓冲区(位于内核态内存区)。 也可显式强制刷新内核缓冲区,fsync() fdatasync() sync()
- 最终,内核发起磁盘操作,将数据传递到磁盘。
除了默认刷新和显示刷新,还可以启动同步,但性能要差很多。
- stdio 库,
setbuf(strem, NULL)
open(path, flags | O_SYNC, mode)
第 19 章 监控文件事件 #
当一组受监控的文件或目录有事件发生(对文件的打开、关闭、创建、删除、修改等操作)时,Linux专有的 inotify
机制可让应用程序获得通知。
第 20 章 信号:基本概念 #
信号是事件发生时对进程的通知机制。有时也称之为软件中断。
常用的信号
SIGABRT
:当进程调用abort()
函数时,系统向进程发送该信号。默认该信号会终止进程,并产生核心转储core文件用于调试。SIGALRM
:经调用alarm()
或setitimer()
而设置的定时器到期,内核将产生该信号。SIGCHLD
:子进程终止时,内核向父进程发送该信号。SIGFPE
:因特定类型的算术错误而产生,比如除以0。SIGHUB
:和控制台操作有关,当用户退出Shell时,由该进程启动的所有进程都会收到HUP信号,默认动作是 exit。SIGINT
:键盘中断,用户按下 Ctrl+C 终止进程。SIGKILL
:无条件终止进程。进程收到这个消息立即终止,不进行清理和暂存工作。 这个消息不能被捕获、忽略或阻塞,所以是杀死进程的终极武器。SIGPIPE
:当进程试图向管道、FIFO或套接字写入信息时,如果这些设备并无相应的读进程,那么将收到该信号。SIGQUIT
:和SIGINT类似,但由QUIT字符(通常是Ctrl+)来控制。SIGSEGV
:进程对内存的引用无效。SIGSTOP
:这是一个必停信号,处理器程序无法将其阻塞、忽略或者捕获,故而总是能停止进程。SIGTERM
:用来终止进程的标准信号,也是不带参数时kill
默认发送的信号。 与 SIGKILL 不同的是,TERM信号可以被阻塞和终止,以便程序退出前可以保存和清理工作。SIGUSR1
:该信号和SIGUSR2
信号供程序员自定义使用。内核绝不会为进程产生这些信号。 进程可以使用这些信号来相互通知事件的发生,或是彼此同步。
20.3 改变信号处置:signal() #
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int sig, sighandler_t handler);
在为 signal()
指定 handler 参数时,可以以如下值来代替函数地址:
- SIG_DFL:将信号处置重置为默认值。这适用于将之前 signal() 调用所改变的信号处置还原。
- SIG_IGN:忽略该信号,内核会默默将其丢弃。进程甚至从未知道曾经产生了该信号。
20.4 信号处理器简介 #
信号处理过程
- 信号到达,主程中断,假设停在指令m
- 内核代表进程去调用信号处理器
- 执行信号处理函数的代码
- 主程从中断点恢复执行,执行m+1
20.5 发送信号:kill() #
一个进程能够使用 kill()
系统调用向另一进程发送信号。
#include <signal.h>
int kill(pid_t pid, int sig);
- 如果 pid 大于 0,那么会发送信号给由 pid 指定的进程。
- 如果 pid 等于 0,那么会发送信号给与调用进程同组的每个进程,包括调用进程自身。
- 如果 pid 小于 −1,那么会向组 ID 等于该 pid 绝对值的进程组内所有下属进程发送信号。
- 如果 pid 等于 −1,调用进程有权将信号发往的每个目标进程,除去 init(进程 ID 为 1)和调用进程自身。 如果特权级进程发起这一调用,那么会发送信号给系统中的所有进程,上述两个进程除外。 显而易见,有时也将这种信号发送方式称之为广播信号。
其他发送信号的方式
raise(sig);
相当于kill(getpid(), sig);
killpg(pid, sig);
相当于kill(-pid, sig);
第 21 章 信号:信号处理器函数 #
一般而言,将信号处理器函数设计得越简单越好。 信号处理器函数设置全局性标志变量并退出。主程序对此标志进行周期性检查,一旦置位随即采取相应动作。 主程序若因监控一个或多个文件描述符的 I/O 状态而无法进行这种周期性检查时, 则可令信号处理器函数向一专用管道写入一个字节的数据,同时将该管道的读取端置于主程序所监控的文件描述符范围之内。
第 23 章 定时器与休眠 #
定时器是进程规划自己在未来某一时刻接获通知的一种机制。 休眠则能使进程(或线程)暂停执行一段时间。
23.1 间隔定时器 #
#include <unistd.h>
unsigned alarm(unsigned seconds);
参数 seconds 表示定时器到期的秒数。到期时,会向调用进程发送 SIGALRM 信号。 它是一次性的,不是定期重复发送该信号。
函数 sleep()
可以暂停调用进程的执行达数秒之久(由参数 seconds 设置),或者在捕获到信号(从而中断调用)后恢复进程的运行。
如果休眠正常结束,sleep()返回 0。如果因信号而中断休眠,sleep()将返回剩余(未休眠)的秒数。
setitimer
比 alarm
更灵活,可用于更多场景,但使用也更加复杂。
setitimer
函数则可以设置重复定时器,即在指定时间间隔内周期性地发送信号。
setitimer
函数需要三个参数,分别是定时器类型、定时器值和定时器到期后的操作。
23.6 POSIX 间隔式定时器 #
setitimer
只能针对 3 类定时器,每种只能设置一个。
timer_create()
创建一个新定时器,并定义其到期时对进程的通知方法。timer_settime()
来启动或停止一个定时器。timer_delete()
删除不再需要的定时器。
第 24 章 进程的创建 #
24.2 创建新进程:fork() #
- 在父进程中,
fork()
将返回新创建的子进程ID - 在子进程中返回 0
- 子进程会获得父进程所有文件描述符的副本。这意味着子进程更新了文件偏移量,也会影响到父进程相应的描述符。
- 使用了写时复制技术来拷贝数据段、堆段、栈段,效率更高。尤其体现在子进程是
execve()
另一个程序的情况。
- 调用
fork()
之后,父子进程谁先执行不确定,不应对父子进程的获得调度先后顺序做任何假设。
24.5 同步信号以规避竞争条件 #
调用 fork()
之后,如果进程甲需等待进程乙完成某一动作,那么乙(即活动进程)可在动作完成后向甲发送信号;甲则等待即可。
第 25 章 进程的终止 #
程序一般不会直接调用 _exit()
,而是调用上层库函数 exit()
,它会在调用 _exit()
前执行各种动作。
调用 exit()
将会引发执行经由 atexit()
和 on_exit()
注册的退出处理程序。
第 26 章 监控子进程 #
系统调用 wait()
等待调用进程的任一子进程终止,同时在参数 status 所指向的缓冲区中返回该子进程的终止状态。
- 调用将一直阻塞,直到有子进程终止
- 返回终止子进程的PID,出错返回-1,可能的错误原因之一是调用进程并无之前未被等待的子进程
更高级的 waitpid()
- pid大于0:表示等待进程ID为pid的子进程
- pid等于0:表示等待与父进程同一进程组的所有子进程
- pid小于-1:等待进程组标识符与pid绝对值相等的所有子进程
- pid等于-1:等待任意子进程。
wait(&status)
与waitpid(-1, &status, 0)
等价 - options参数可以做到非阻塞,WNOHANG标志
26.2 孤儿进程与僵尸进程 #
在父进程执行 wait()
之前,子进程已经终止,系统仍然允许其父进程在之后的某一时刻去执行 wait()
,以确定该子进程是如何终止的。
内核通过将子进程转为僵尸进程(zombie)来处理这种情况。
子进程终止后将释放大部分资源,唯一保留的是在内核进程表一条记录,包含子进程ID等信息。
如果父进程一直未执行 wait()
,那么僵尸进程将一直存在,直到父进程调用 wait()
内核将删除僵尸进程。
如果父进程还未执行 wait()
自己就终止了,那么init进程将接管子进程,并自动调用 wait()
。
26.3 SIGCHLD 信号 #
- 父进程调用不带 WNOHANG 标志的 wait(),或 waitpid()方法,此时如果尚无已经终止的子进程,那么调用将会阻塞。
- 父进程周期性地调用带有 WNOHANG 标志的 waitpid(),执行针对已终止子进程的非阻塞式检查(轮询)。 这两种方法使用起来都有所不便。 一方面,可能并不希望父进程以阻塞的方式来等待子进程的终止。 另一方面,反复调用非阻塞的 waitpid()会造成 CPU 资源的浪费,并增加应用程序设计的复杂度。 为了规避这些问题,可以采用针对 SIGCHLD 信号的处理程序。
子进程终止,系统会向父进程发送SIGCHLD信号。 可以在信号处理函数里循环调用非阻塞的waitpid,用循环的原因是可能相同的两次等待信号,父进程只收到一次。返回0表示再无僵尸子进程。
while (waitpid(-1, NULL, WNOHANG) > 0)
continue;
如果程序对子进程退出后的状态不感兴趣的话可以调用 signal(SIGCHLD, SIG_IGN);
交给系统init去回收。子进程也不会产生僵尸进程了。
第 27 章 程序的执行 #
int execve(const char *pathname, argv[], envp[]);
加载新程序到内存空间执行,构建于execve
之上还有很多exec开头的函数。int system(const char *command);
创建一个子进程来运行shell,并执行command命令。 优点是简便,但效率低,因为至少创建了两个进程,一个是shell,另一个是command。
第 28 章 详述进程创建和程序执行 #
28.2 系统调用 clone() #
int clone(func, ...);
类似于 fork()
,Linux 特有的系统调用 clone()
也能创建一个新进程。
不同的是,后者在进程创建期间对步骤的控制更为精准。
克隆生成的子进程会调用func指定的函数,当函数返回后,子进程就终止了。
第 29 章 线程:介绍 #
一个进程可以包含多个线程,线程共享同一份全局内存区域,即共享相同的全局和堆变量,但每个线程都有局部变量的私有栈。
Linux是通过 clone()
来实现线程的。
在多线程程序中,每个线程都有属于自己的 errno
。
29.3 创建线程 #
pthread_create(&thread, NULL, start, &arg)
- 新线程通过调用带有参数 arg 的函数 start(即 start(arg))而开始执行。
- 新线程ID通过 thread 指针返回给调用线程
- 新线程可通过
pthread_self()
获取自己的线程ID
- 新线程可通过
- 调用 pthread_create() 的线程会继续执行该调用之后的语句。
29.4 终止线程 #
- 线程 start 函数执行 return 语句并返回指定值
- 线程调用
pthread_exit()
终止线程 - 其他线程调用
pthread_cancel()
取消线程 - 任意线程调用了
exit()
,那么所有线程将立即终止
29.6 连接(joining)已终止的线程 #
pthread_join(thread, retval)
等待线程终止,retval 是线程的返回值。类似与waitpid,如果未调用也会有僵尸线程
29.7 线程的分离 #
如果不关心线程返回值,只是希望系统在线程终止时能够自动清理并移除之,则可以调用 pthread_detach(thread)
第 30 章 线程:线程同步 #
30.1 保护对共享变量的访问:互斥量 #
线程提供的强大共享是有代价的,需要协调共享变量的访问。 互斥量(mutex)可以确保同时仅有一个线程可以访问某项共享资源,原子操作。
互斥量有两种状态:已锁定(locked)和未锁定(unlocked)。 任何时候,至多只有一个线程可以锁定该互斥量。 试图对已经锁定的某一互斥量再次加锁,将可能阻塞线程或者报错失败,具体取决于加锁时使用的方法。 一旦线程锁定互斥量,随即成为该互斥量的所有者。只有所有者才能给互斥量解锁。
pthread_mutex_lock(&mutex)
如果互斥量当前处于未锁定状态,将锁定并立即返回; 如果已经被锁定,将阻塞。 当需要多个互斥量时,有死锁可能,只需设计出层次锁定顺序即可,每个线程总是先锁定mutex1再锁定mutex2。pthread_mutex_unlock(&mutex)
pthread_mutex_init()
对互斥量进行动态初始化pthread_mutex_destroy()
30.2 通知状态的改变:条件变量(Condition Variable) #
条件变量允许一个线程就某个共享变量的状态变化通知其他线程,并让其他线程等待(堵塞于)这一通知。
条件变量总是结合互斥量使用。 条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥。
条件变量的主要操作是发送信号(signal)和等待(wait)。
pthread_cond_signal(&cond)
针对由参数 cond 所指定的条件变量而发送信号,只保证唤醒至少一条遭到阻塞的线程pthread_cond_broadcast(&cond)
针对由参数 cond 所指定的条件变量而发送信号,唤醒所有遭阻塞的线程pthread_cond_wait(&cond, &mutex)
阻塞线程,直至收到条件变量 cond 发出的信号。 会解锁互斥量 mutex;阻塞线程,直到另一线程就条件变量 cond 发出信号;重新锁定 mutexpthread_cond_init(&cond, &attr)
pthread_cond_destroy(&cond)
threads/prod_condvar.c
有个生产者-消费者的使用例子
第 31 章 线程:线程安全 #
若函数可同时供多个线程安全调用,则称之为线程安全函数; 反之,如果函数不是线程安全的,则不能并发调用。
使用全局或静态变量是导致函数非线程安全的通常原因。 在多线程应用中,保障非线程安全函数安全的手段之一是运用互斥锁来防护对该函数的所有调用。 这种方法带来了并发性能的下降,因为同一时点只能有一个线程运行该函数。 提升并发性能的另一方法是:仅在函数中操作共享变量(临界区)的代码前后加入互斥锁。 使用互斥量可以实现大部分函数的线程安全,不过由于互斥量的加、解锁开销,故而也带来了性能的下降。 如能避免使用全局或静态变量,可重入函数则无需使用互斥量即可实现线程安全。
31.3 线程特有数据 #
ThreadLocal 实现
31.4 线程局部存储 #
static __thread buf[MAX_LEN];
在全局或静态变量的声明中包含 __thread
说明符即可。
每个线程都拥有一份对变量的拷贝。线程局部存储中的变量将一直存在,直至线程终止,届时会自动释放这一存储。
第 32 章 线程:线程取消 #
函数 pthread_cancel(thread)
向指定的线程发送一个取消请求。
发出取消请求后,函数 pthread_cancel()
当即返回,不会等待目标线程的退出。
目标线程如何响应,取决于其取消性状态和类型。
第 34 章 进程组、会话和作业控制 #
进程组是一组相关进程的集合,会话是一组相关进程组的集合。 会话和进程组的主要用途是用于 shell 作业控制。
进程组由一个或多个共享同一进程组标识符(PGID)的进程组成。 一个进程组拥有一个进程组首进程,该进程是创建该组的进程,其进程 ID 为该进程组的 ID,新进程会继承其父进程所属的进程组 ID。 进程组拥有一个生命周期,其开始时间为首进程创建组的时刻,结束时间为最后一个成员进程退出组的时刻。
进程的会话成员关系是由其会话标识符(SID)确定的,会话首进程是创建该新会话的进程,其进程 ID 会成为会话 ID。 新进程会继承其父进程的会话 ID。
一个会话中的所有进程共享单个控制终端。 控制终端会在会话首进程首次打开一个终端设备时被建立。 一个终端最多可能会成为一个会话的控制终端。
在任一时刻,会话中的其中一个进程组会成为终端的前台进程组,其他进程组会成为后台进程组。 只有前台进程组中的进程才能从控制终端中读取输入。 当用户在控制终端中输入其中一个信号生成终端字符之后,该信号会被发送到前台进程组中的所有成员。 这些字符包括生成 SIGINT 的中断字符(通常是 Control-C)、生成 SIGQUIT 的退出字符(通常是 Control-\)、生成 SIGSTP 的挂起字符(通常是 Control-Z)。
当到控制终端的连接建立起来(即打开)之后,会话首进程会成为该终端的控制进程。
成为控制进程的主要标志是当断开与终端之间的连接时内核会向该进程发送一个 SIGHUP 信号。
控制进程通常是一个 shell。
shell 建立了一个 SIGHUP 信号的处理器,这个处理器会终止 shell,但在终止之前会向由 shell 创建的各个进程组(包括前台和后台进程组)发送一个 SIGHUP 信号。
nohup
命令可以用来使一个命令对 SIGHUP 信号免疫——即执行命令时将 SIGHUP 信号的处理设置为 SIG_IGN。
第 35 章 进程优先级和调度 #
35.1 进程优先级(nice 值) #
进程特性 nice 值允许进程间接地影响内核的调度算法。 每个进程都拥有一个 nice 值,其取值范围为−20(高优先级)~19(低优先级),默认值为 0。
35.4 CPU 亲和力 #
当一个进程在一个多处理器系统上被重新调度时无需在上一次执行的 CPU 上运行。 进程切换 CPU 时对性能会有一定的影响,有两种亲和:
- 软亲和:在条件允许的情况下进程重新被调度到原来的CPU上运行
- 硬亲和:显式地将其限制在可用 CPU 中的一个或一组 CPU 上运行
第 36 章 进程资源 #
36.1 进程资源使用 #
getrusage()
系统调用返回调用进程或其子进程用掉的各类系统资源的统计信息。
36.2 进程资源限制 #
getrlimit()
和 setrlimit()
系统调用允许一个进程读取和修改自己的资源限制。
第 41 章 共享库基础 #
41.2 静态库 #
静态库:连接器在解析了引用情况后,会从库中抽取所需目标模块的副本,将其复制到最终的可执行文件中。
- 因为每个程序都拷贝副本,磁盘上有重复浪费,如果多个程序加载运行,也会造成内存浪费
- 如果对库函数进行了修改,整个程序都需要重新编译
- 虽然一个静态库可以包含很多目标模块,但链接器只会包含那些程序需要的模块
# 创建静态库,`r` 参数是替换的意思
gcc -g -c mod1.c mod2.c mod3.c
ar r libdemo.a mod1.o mod2.o mod3.o
rm mod1.o mod2.o mod3.o
# 查看静态库
ar tv libdemo.a
# 删除一个模块
ar d libdemo.a mod3.o
# 使用静态库
gcc -g -c prog.c
gcc -g -o prog prog.o libdemo.a
# 或将静态库放在 /usr/lib,可用 -l 选项指定库名
gcc -g -o prog prog.o -ldemo
41.3 共享库 #
动态库:链接器不复制库中的目标,而是在可执行文件中写入一条记录,以表明在运行时需要使用哪些共享库。
# 创建共享库,-fPIC 选项指定编译器应该生成位置独立的代码
gcc -g -c -fPIC -Wall mod1.c mod2.c mod3.c
gcc -g -shared -o libfoo.so mod1.o mod2.o mod3.o
# 使用共享库
gcc -g Wall -o prog prog.c libfoo.so
# 因为程序所需的库位于当前工作目录中,而不位于动态链接器搜索的标准目录清单中
LD_LIBRARY_PATH=. ./prog
# 列出依赖的动态库
ldd prog
# 查看本机安装的动态库
ldconfig -v
第 44 章 管道和 FIFO #
pipe(filedes)
系统调用创建一个新管道,数组 filedes 中返回两个打开的文件描述符:
一个表示管道的读取端filedes[0],另一个表示管道的写入端filedes[1]。
虽然父进程和子进程都可以从管道中读取和写入数据,但这种做法并不常见。
因此,在 fork()
调用之后,其中一个进程应该立即关闭管道的写入端的描述符,另一个则应该关闭读取端的描述符。
FIFO 与管道类似,最大的差别在于 FIFO 在文件系统中拥有一个名称,并且其打开方式与打开一个普通文件是一样的。 这样就能够将 FIFO 用于非相关进程之间的通信(如客户端和服务器)。 FIFO 有时候也被称为命名管道。
mkfifo(pathname, mode)
函数创建一个名为 pathname 的全新的 FIFO。
一般使用 FIFO 的做法是在两端分别设置一个读取进程和一个写入进程。
默认情况,当一个进程打开一个 FIFO 的一端时,如果 FIFO 的另一端还没有被打开,那么该进程会被阻塞。
第 45 章 System V IPC 介绍 #
System V IPC 是首先在 System V 中被广泛使用的三种 IPC 机制的名称并且之后被移植到了大多数 UNIX 实现中以及被加入了加入了各种标准中。 这三种 IPC 机制允许进程之间交换消息的消息队列,允许进程同步对共享资源的访问的信号量,以及允许两个或更多进程共享内存的同一个页的共享内存。
最好避免使用 System V 消息队列。
第 47 章 System V 信号量 #
TODO: 不太理解,相比 mutex,可以有多个并发存在,但多个并发如何处理竞争问题呢?
第 48 章 System V 共享内存 #
共享内存允许两个或多个进程共享内存的同一个分页。 由于一个共享内存段会成为一个进程用户空间内存的一部分,因此这种 IPC 机制无需内核介入。 所有需要做的就是让一个进程将数据复制进共享内存中,并且这部分数据会对其他所有共享同一个段的进程可用。 与管道或消息队列要求发送进程将数据从用户空间的缓冲区复制进内核内存和接收进程将数据从内核内存复制进用户空间的缓冲区的做法相比,这种 IPC 技术的速度更快。
第 49 章 内存映射 #
mmap()
系统调用在调用进程的虚拟地址空间中创建一个新内存映射。
映射分为两种:基于文件的映射和匿名映射。 文件映射将一个文件区域中的内容映射到进程的虚拟地址空间中。 匿名映射并没有对应的文件区域,该映射中的字节会被初始化为 0。
第 51 章 POSIX IPC 介绍 #
POSIX IPC 提供的接口在很多方面都优于 System V IPC 接口。
第 56 章 SOCKET:介绍 #
56.1 概述 #
- 三种 socket domain:AF_UNIX(只可本机通信)、AF_INET(ipv4)、AF_INET6(ipv6)
- 两种 socket 类型:SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)
56.2 创建一个 socket:socket() #
int socket(int domain, int type, int protocol);
返回新创建的 socket 的文件描述符
56.3 将 socket 绑定到地址:bind() #
bind()
系统调用将一个 socket 绑定到一个地址上。
通常,服务器需要使用这个调用来将其 socket 绑定到一个众所周知的地址上使得客户端能够定位到该 socket 上。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd 参数是在上一个 socket() 调用中获得的文件描述符。
- addr 参数是一个指针,它指向了一个指定该 socket 绑定到的地址的结构。 传入这个参数的结构的类型取决于 socket domain。
- addrlen 参数指定了地址结构的大小。
56.5 TCP socket #
int listen(int sockfd, int backlog);
允许一个流 socket 接受来自其他 socket 的接入连接。
int accept(int sockfd, struct sockaddr *addr, socklen_t addrlen);
在一个监听流 socket 上接受来自一个对等应用程序的连接,并返回对等 socket 的地址(通过addr返回)。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
客户端建立与服务端 socket 之间的连接。
close()
连接终止
56.6 UDP socket #
recvfrom()
和 sendto()
系统调用在一个数据报 socket 上接收和发送数据报。
第 57 章 SOCKET:UNIX DOMAIN #
拥有 socket 文件的写和执行权限的进程才能够与这个 socket 进行通信。
第 59 章 SOCKET:Internet Domain #
第 60 章 SOCKET:服务器设计 #
两种常见的设计方式
- 迭代型:每次只处理一个客户端,只有当完全处理完一个客户端的请求后才去处理下一个客户端。
- 并发型:能够同时处理多个客户端的请求。服务器主进程的主要任务就是为每个新的客户端连接创建一个新的子进程(线程)。
优化方案
- 在服务器上预先创建进程或线程,在处理完客户端请求后,子进程并不终止,而是获取下一个待处理的客户端继续处理
- 在单个进程中处理多个客户端,采用一种能允许单个进程同时监视多个文件描述符上 I/O 事件的 I/O 模型
60.5 inetd(Internet 超级服务器)守护进程 #
守护进程 inetd 可以监视多个套接字,并启动合适的服务器进程作为到来的 UDP 数据报或 TCP 连接的响应。 通过使用 inetd,可以将运行在系统上的网络服务进程的数量降到最小,从而降低系统的整体负载。
第 61 章 SOCKET:高级主题 #
61.4 sendfile()系统调用 #
while ((n = read(diskfilefd, buf, BUZ_SIZE)) > 0)
write(sockfd, buf, n)
这种方式不高效,原因是 read()
将文件内容从内核缓冲区拷贝到用户空间,write()
将用户空间缓冲区拷贝回socket发送缓冲区内核空间
当应用程序调用 sendfile()
时,文件内容会直接传送到套接字上,而不会经过用户空间。
这种技术被称为零拷贝传输(zero-copy transfer)。
61.6 深入探讨 TCP 协议 #
第 63 章 其他备选的 I/O 模型 #
63.2 I/O 多路复用 #
I/O 多路复用允许我们同时检查多个文件描述符,看其中任意一个是否可执行 I/O 操作。
系统调用 select()
会一直阻塞,直到一个或多个文件描述符集合成为就绪态。
int select(int nfds, fd_set *restrict readfds, fd_set *restrict writefds,
fd_set *restrict errorfds, struct timeval *restrict timeout);
- readfds 是用来检测输入是否就绪的文件描述符集合。
- writefds 是用来检测输出是否就绪的文件描述符集合。
- errorfds 是用来检测异常情况是否发生的文件描述符集合。
- timeout 该参数可指定为 NULL,此时 select() 会一直阻塞
关于文件描述符集合的操作都是通过四个宏来完成的:
FD_ZERO()
将 fdset 所指向的集合初始化为空。FD_SET()
将文件描述符 fd 添加到由 fdset 所指向的集合中。FD_CLR()
将文件描述符 fd 从 fdset 所指向的集合中移除。FD_ISSET()
如果文件描述符 fd 是 fdset 所指向的集合中的成员,返回 true。
select()
返回所有在 3 个集合中被标记为就绪态的文件描述符总数。
后续正常流程每个返回的文件描述符集合都需要检查(通过 FD_ISSET()),以此找出发生的 I/O 事件是什么。
系统调用 poll()
执行的任务同 select()
很相似。
两者间主要的区别在于我们要如何指定待检查的文件描述符。
性能问题
- 每次调用 select()或 poll(),内核都必须检查所有被指定的文件描述符,看它们是否处于就绪态。
- select()或 poll()调用完成后,程序必须检查返回的数据结构中的每个元素,以此查明哪个文件描述符处于就绪态了。
63.3 信号驱动 I/O #
在信号驱动 I/O 中,当文件描述符上可执行 I/O 操作时,进程请求内核为自己发送一个信号。 默认情况下,这个通知信号为 SIGIO。
63.4 epoll 编程接口 #
- 系统调用
epoll_create()
创建了一个新的 epoll 实例,其对应的兴趣列表初始化为空。 - 系统调用
epoll_ctl()
能够修改由文件描述符 epfd 所代表的 epoll 实例中的兴趣列表。 可以增加新的描述符到列表中,将已有的文件描述符从该列表中移除,以及修改代表文件描述符上事件类型的位掩码。 - 系统调用
epoll_wait()
返回 epoll 实例中处于就绪态的文件描述符信息。 单个 epoll_wait() 调用能返回多个就绪态文件描述符的信息。
TODO: epoll 到底靠什么提升了性能