Linux 相关面试知识

概念

常用命令

  • grep命令

    1

    2

    3

    4

    grep -A 3 "pattern" file # 输出匹配行及其后面的3行,after

    grep -B 3 "pattern" file # 输出匹配行及其前面的3行,before

    grep -C 3 "pattern" file # 输出匹配行及其前后的3

    grep -3 "pattern" file   # 输出匹配行及其前后的3

  • sed命令:文本流编辑,对指定的每一行执行对应的命令,行是通过地址指明的。因此主要包含指定地址的方式和命令。

    • 指定地址(基于行号)的方式

      • n 行号, n 是一个正整数.
      • $ 最后一行.
      • /regexp/ 使用BRE, 注意正则表达式通过斜杠字符界定, 也可以选择其他字符界定, 通过\cregexpc 来指定表达式, 这里c就是一个选择的界定字符.
      • line1,line2 闭区间, line1 line2可以使上面的任意形式. 即允许 /re1/,/re2/ 的形式.
      • first~step 匹配由数字 first 代表的文本行, 然后随后的每个在 step 间隔处的文本行.
      • line1,+n 匹配地址 line1 和随后的 n 个文本行.
      • line! 匹配所有的文本行, 除了 line 之外, line 可能是上述任意的地址形式. 注意在双引号中要用\修饰!, 单引号中则不必.
    • 命令

      • s命令:替换。形式为 sed "[addr]s/pattern/replace/[g12...]" addr可以使上面的语法指定,不指定时默认为1,$.
        pattern支持BRE,默认只替换每行第一个匹配的,g表示替换所有, 1,2...表示仅替换第k个.
      • d命令:删除行。形式为 sed "[addr]d"
      • N命令:把下一行的内容放入当前行之后, 一起放入缓冲区. 一般搭配其他命令一起使用。
      • a命令:append一行。形式为 sed "[addr]a str",表示在addr指定的行后添加一行.
      • i命令:insert一行。形式为 sed "[addr]i str",表示在addr指定的行前添加一行.
      • c命令:替换一行。形式为 sed "[addr]c str"
      • p命令:打印行。形式为 sed "[addr]p". 不过sed默认输出所有行, 可以使用-n选项关闭.
    • 注意:

      • 可以在一个引号内执行多个命令, 只需要用分号";"隔开即可
      • 可以使用大括号嵌套命令, 如:sed "1,${/hahaha/d;s/shabi/sb/g}" file
    • pattern space的概念,下面是sed处理文本的伪代码,表明每处理一行之前, 会先将该行读入pattern space,
      当没有-n选项时, 在处理完后将pattern space 的内容输出.

      1

      2

      3

      4

      5

      6

      7

      8

      9

      10

      11

      12

      13

      foreach line in file

      {

          //放入把行Pattern_Space

          Pattern_Space <= line;

       

          // 对每个pattern space执行sed命令

          Pattern_Space <= EXEC(sed_cmd, Pattern_Space);

       

          // 如果没有指定 -n 则输出处理后的Pattern_Space

          if (sed option hasn't "-n")  {

             print Pattern_Space

          }

      }

    • hold space的概念:也是一个缓冲区, 但是sed不直接使用它, 由程序员显式使用. 使用方法如下.

      • g:将hold space中的内容拷贝到pattern space中, 原来pattern space里的内容清除
      • G:将hold space中的内容append到pattern space\n后
      • h:将pattern space中的内容拷贝到hold space中, 原来的hold space里的内容被清除
      • H:将pattern space中的内容append到hold space\n后
      • x:交换pattern space和hold space的内容

      例如, 下面的命令将文件行反序:

      1

      2

      sed "1\!G;h;\$\!d" file

      sed -n "1\!G;\$\!h;\$p" file

      1

      2

      3

      4

      5

      6

      7

      8

      9

      10

      11

      12

      13

      14

      # 将文件file的第110行匹配pattern的部分替换为 replace, 默认只替换第一个,指定最后的g可以替换所有。

      # s 表示替换

      sed "1,10s/pattern/replace/[g12...]" file

       

      # 删除存在匹配pattern的字符串的行

      sed "/pattern/d"

       

      # 将文件 file 的每两行合并,偶数行在奇数行之后。

      # N的作用是提前读取下一行,放在当前缓冲区(此时已经保存了当前行)之后。“;”用于分隔两个命令,

      # s将缓冲区中的换行替换为逗号,于是将两行合并为一行。

      sed "N;s/\n/," file

       

      # 将文件按行反序

      sed "1\!G;h;\$\!d" file

  • awk:用于文本处理和分析,可以参考awk命令

    awk工作方式类似sed,也是读取文本的一,然后对该行执行指定的命令。

    awk命令的模式一般如下所示,-F后面的参数指定了分隔字符,用于将一行分隔为多个字段
    在随后的命令中可以用 1 表示第一个字段,1表示第一个字段,0表示整行。
    第二个单引号中的部分就是这里说得命令。其中的 commands 可以是多个命令,由分号分隔。

    1

    awk -F ':' 'BEGIN{commands} pattern{commands} END{commands}'

    首先是 BEGIN{commands} 部分,commands 是在读取第一行之前执行的命令,一般用于做初始化工作,
    例如将计数器初始化为0、打印标题等。这一部分可以省略。
    然后是 pattern{commands} 部分,此时会对文本的每一行执行 commands 命令,pattern为模式匹配项,
    用于判断处理到当前行时,是否满足pattern指定的条件,满足才会执行后面的commands,留空时表示不过滤。
    pattern可以是正则表达式、关系表达式、模式匹配表达式等。
    最后是 END{commands} 部分,当处理完文本后,最后执行这里指定的命令。例如输出最后的统计信息。

  • umask:限制文件的默认权限。

    1

    umask 022 # 新建文件的权限是 777-022=755, 即rwxr-xr-x

  • 进程栈, 线程栈, 内核栈, 中断栈

    首先介绍linux下程序的内存结构. 从低地址到高地址, 如下所示. 堆是向上增长, 栈是向下增长.
    堆栈之间是空闲的空间, mmap 使用的就是这一段空间.

    • 代码段
    • 数据段
    • BSS段: 保存未初始化的静态变量, 会被进行 0 初始化. 因此生成的可执行文件中不需要记录这一段的值,
      只需要在加载到内存中时在内存中开辟对应大小的空间即可.
    • 内核栈

    进程栈就是上面的栈, 随着函数调用不断增长.
    线程栈由 mmap 获得, 即与进程在同一地址空间, 可以参考Linux下线程的实现. 注意线程栈是事先分配好的, 不能动态增长.
    内核栈在地址空间的最高地址处, 当用户调用系统调用时, 就进入内核栈. 进程间的内核栈是互相独立的.
    中断栈用于运行中断响应例程.

进程

一个进程关联的用户有很多:

  • 实际用户id, 实际组id: 是谁在执行本进程, 一般是执行程序的用户, 在登录会话期间不变, root进程可以改变此值.
  • 有效用户id, 有效组id: 用于文件访问权限检查, 一般就是实际用户/组ID, 但是如果可执行文件设置了set-user-ID位和set-group-ID位,
    则执行此文件时, 将文件的所有者/所在组设置为有效用户/组ID. 该权限位在chmod命令中用 s 表示(一般替代x).
  • 保存的设置用户id, 保存的设置用户id: 由exec函数保存.

Linux下进程有5种状态:

  • task_running: 进程已就绪或正在执行.
  • task_interruptible: 进程被阻塞, 处于休眠状态, 当等待的条件满足时可以转变为 task_running.
    当收到信号时也可以被唤醒而运行.
  • task_uninterruptible: 进程被阻塞, 处于休眠状态, 当等待的条件满足时可以转变为 task_running.
    但是不能被信号唤醒. 例如在终端中kill某些进程时无法结束进程, 这些进程就处于此状态.
    kill 默认发送的是 TERM 信号, 可以被屏蔽. 可以改为发送 KILL 信号, 此信号无法进程被捕获, 动作是直接结束进程.
  • task_traced: 进程被其他进程跟踪.
  • task_stopped: 进程停止执行. 当进程收到 SIGSTOP, SIGSTP, SIGTTIN, SIGTTOU 等信号时会转变为此状态.

进程调度(CFS, completely fair scheduler)

CFS调度策略不是将nice值映射为固定的时间片长度, 而是将其作为分配时间片的权值,
即nice值大的进程分配的时间片比nice值小的进程获得的时间片短.

linux 对实时进程普通进程采用不同的调度策略.

对于实时进程, linux 分为两种: 先进先出的实时进程(SCHED_FIFO)时间片轮转的实时进程(SCHED_RR).
对于前者, linux 采用抢占式的优先级调度算法, 进程可以一直占用cpu直到被更高优先级的进程抢占或进程终止/阻塞.
对于后者, linux 同样支持高优先级进程抢占, 并且进程阻塞/终止/时间片用尽时也会放弃cpu.
实时进程的优先级为0-99.

每个普通进程都有自己的静态优先级(100到139, 100表示最高优先级, 139表示最小)和动态优先级(100-139, 100最高, 139最小).
子进程会继承父进程的静态优先级, 也可以通过 nice() 和 setpriority() 来改变自己拥有的进程的静态优先级.
100到139分别对应了nice值的-20到19.

静态优先级决定了进程的基本时间片, 值越小, 静态优先级越高, 时间片越长.
基本时间片是进程用完了之前的时间片时, 系统分配给进程的时间片长度.
动态优先级是调度程序在选择新的进程来运行时使用的, 是通过静态优先级和 bonus([0, 10]) 计算得到,
bonus 与进程的平均睡眠时间正相关, 这避免了进程饿死.

动态优先级 = max(100, min(静态优先级 - bonus + 5, 139))动态优先级=max(100,min(静态优先级−bonus+5,139))

为了防止进程饿死, linux将进程分为活动进程过期进程.
活动进程指那些还没有用完自己时间片的进程, 过期进程指已经用完了时间片, 但是还可以继续运行(即没有阻塞, 处于就绪状态)的进程.
活动进程被允许运行, 而过期进程要等活动进程运行完之后才可以运行.
但是为了保证交互式进程能及时被响应, 即使已经用完了时间片, 调度程序也会将其作为活动进程.

每个CPU都有一个数据结构(rq, kernel/sched/sched.h), 记录了自己的运行队列. 其中有多个不同的调度类成员, 保存着对应的进程.

linux中定义了多种调度类(scheduler class), 不同的调度类执行不同的调度算法.
例如 cfs 就是一种调度类, 采用的是 cfs 调度算法, 一般普通进程使用此调度类.
还有 rt 调度类(real time, 实时), 实时进程采用此调度类, 还有 dl 调度类(dead line).
调度类内部一般用红黑树组织进程, 将进程优先级作为键值, 保证了log(n)的复杂度.
调度类实现了标准接口, 可以从其中选出最先运行的进程, 或者加入/删除进程.

shedule() 实现了调度程序, 在其任务是从运行队列的链表中找到一个进程, 并将CPU分配给该进程, 使之开始运行.
shedule() 由几个内核控制路径调用, 可以采取直接调用延迟调用的方式.

直接调用: 如果当前的进程阻塞(如等待IO), 调度程序会被调用. 过程如下:

  1. 将当前进程插入到对应的等待队列.
  2. 将当前进程的状态设为 task_interruptible 或 task_uninterruptible.
  3. 调用shedule().
  4. 检查资源是否可用, 如果不可用就转到第2步, 如果可用就将进程从等待队列中移除.

延迟调用: 可以将当前进程的 TIF_NEED_RESCHED 标志设为1, schedule() 会在随后的某个时刻被明确调用.
这是因为内核返回用户态前(即从系统调用中退出)会检查此标志, 如果标志被设置, 就会调用 schedule() 函数.
例如:

  • 当前进程用完CPU时间片时, 内核会设置该标志, 随后 schedule() 被调用.
  • 当一个被唤醒的进程的优先级比当前进程优先级高时.
  • 当调用系统调用 sched_setshceduler() 时.

那么, schedule() 函数如何要运行的进程呢. schedule() 选择优先级最高的调度类(rt > cfs),
从中选出优先级最高(注意说的这不是前面的动态优先级, 动态优先级越高, 这里的优先级越低)的使之运行.

进程间的关系

进程组(process group)是一个或多个进程的集合. 每个进程都有一个进程组id(pgid)表明自己属于哪个进程组.

  • 每个进程组有一个组长进程, 组长进程的pid等于进程组的pgid.
  • 进程组一直存在, 直到进程组中的进程都终止/离开, 而与组长进程是否终止没有关系. 进程组从出现到最后一个进程终止/离开的时间称为其生命期.
  • 子进程默认父进程的进程组id, 每个进程都可以设置自己和子进程的进程组. 当子进程执行了 exec 之后父进程就不能设置其pgid.
  • 进程可以指定新的进程组id为自己的pid, 从而创建新的进程组, 并使自己成为进程组组长.
  • 通常用管道将几个进程编为一组, 例如在shell中用管道把几个命令连接起来. 通常shell会为每个命令创建一个独立的进程,
    并他们组织为一个进程组, 第一个命令对应的进程是进程组组长. 也将此进程组称为一个作业(task).

会话(session)是一个或多个进程组的集合, 每个会话由一个会话首进程(session leader)创建,
通常也将会话首进程的pid作为会话的会话id会话首进程是该进程所属进程组的进程组长.

可以调用 setsid() 来新建一个会话, 这也会导致新的进程组的建立. 要求当前进程不是进程组组长, 步骤如下:

  1. 建立新的会话, 进程成为新会话的会话首进程. 此时, 会话中只有一个进程.
  2. 建立新的进程组, 进程成为新的进程组组长, 新进程组id是当前进程的pid.
  3. 进程没有控制终端. 如果在调用 setsid 之前该进程有一个控制终端, 联系也会被切断.

由于 setsid 要求进程不能是进程组组长, 因此一般先调用 fork, 父进程结束, 在子进程中调用 setsid, 就能保证这个要求.


在一个终端创建进程时, 可以将部分进程组设置为后台执行(在命令后面加 &), 将后台执行的进程组称为后台进程组,
在前台执行的称为前台进程组. 二者都属于同一会话, 会话首进程一般是shell进程.

区分前台/后台进程组的原因是: 前台/后台进程组都可以向控制终端写, 但是控制终端的输入和信号只会发送给前台进程组的所有进程.
例如在键盘按下 ctrl+c 的时候, 终端产生一个 SIGINT 信号, 发送给前台进程组的所有进程.
唯一的例外是在远程登录/退出终端时, 如果终端接口检测到网络已断开连接, 就将挂断信号发送给会话首进程(控制进程).

  • ctrl + c: SIGINT, 中断进程
  • ctrl + : SIGQUIT, 退出
  • ctrl + z: SIGTSTP, 挂起进程.

守护进程(daemon)是一种生存期很长的进程, 通常在系统引导装入时启动, 系统关机时终止, 没有控制终端.
使一个进程成为守护进程的步骤为:

  1. 调用mask修改文件模式创建屏蔽字, 例如设为0.
  2. 调用fork, 再结束父进程, 在子进程中继续进行操作. 这是为了防止当前进程是进程组的组长.
  3. 调用setsid创建一个新的会话. 这会使得当前进程成为会话首进程, 并成为新的进程组的组长进程, 并切断与控制终端的联系.
  4. 调用chdir/fchdir系统调用, 更改进程的当前工作目录, 例如可以更改为根目录. 这是因为进程初始的工作目录可能需要被卸载,
    如果不改变工作目录, 会导致其无法被卸载.
  5. 关闭不再需要的文件描述符. 可以通过 getrlimit 函数来获取合法的最大文件描述符号(尽管进程可能没有使用到),
    再遍历所有合法的文件描述符, 一一关闭他们.
  6. 将标准输入/输出, 错误输出(即0, 1, 2文件描述符)绑定到某些文件, 例如/dev/null. 这样, 当进程中有标准输入输出时不会有任何效果.

通常linux系统会实现一个集中的守护进程出错记录设施, 守护进程的错误输入可以通过提供的接口输出.

单实例守护进程可以通过文件锁实现, 即同一守护进程对同一文件加锁, 失败则退出, 就可以保证只会有一个同样的守护进程.

进程间通信

下面这些通信方式的实现大都是这样:由内核维护一个数据结构,对数据结构的访问是经过同步的(原子操作或某种锁,如自旋锁等),
当无法获取资源,需要需要等待时,就将进程加入到数据结构包含的等待队列中。一旦资源就绪,内核就将进程转移为就绪状态。

  • 匿名管道

    一般是半双工, 使用 int pipe(int fd[]) 函数创建, fd[0]用于读, fd[1]用于写.
    fork子进程后, 在父子进程中分别关闭读/写文件描述符, 之后就可以开始写/读, 以实现父子进程的通信.
    多个进程可以同时写管道, 只要写的数据量不超过PIPE_BUF, 写操作就是原子的, 不会发生交叉.

    FILE* popen(const char *cmd, const char *type) 函数会创建子进程执行 cmd 命令,
    并在父子进程之间建立一个管道, type 指明是父进程是读还是写, 对应子进程是写/读管道,
    对应的管道会被绑定到进程的标准输出/输入.

    有引用计数, 当关联进程全部退出, 管道就会被删除.

  • 命名管道(FIFO)

    命名管道基于FIFO类型的文件, 可以在两个无关进程中使用.

    首先通过 int mkfifo(const char *path, mode_t mode) 创建FIFO文件, 再在进程中用 open 函数打开文件,
    就可以实现进程间的通信.

    多个进程可以同时写管道, 只要写的数据量不超过PIPE_BUF, 写操作就是原子的, 不会发生交叉.

    有引用计数, 当关联进程全部退出, 管道就会被删除.

  • XSI IPC

    在内核中维护一个IPC结构, 用一个非负整数标识符标识, 每个IPC结构都关联一个键(key),
    进程通过键来使用IPC结构.

    进程可以通过指定 key = IPC_PRIVATE 来新建一个IPC结构,
    也可以通过 key_t ftok(const char *path, int id) 函数来获得一个 key, 然后通过其他函数创建key指定的IPC结构.

    XSI IPC 没有引用计数, 因此当向一个消息队列发送数据之后, 消息队列和数据会一直留在系统中, 直到:
    某个进程接收消息或删除队列; 系统重启时删除队列.

  • XSI消息队列

    int msgget(key_t key, int flag) 可以获取一个消息队列, 返回一个 msqid,
    之后可以通过msgsnd, msgrcv函数来向此队列发送/接受数据.

    int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag)函数用于向队列发送消息.
    ssize_t msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag)用于从队列中取出消息.

    不一定是先进先出, 可以通过type参数获取指定类型的消息.

  • XSI信号量

    实际上是一个计数器, 用于为多个进程提供对共享数据对象的访问.

    在进行信号量操作申请资源时, 如果指定了SEM_UNDO, 那么内核会记住调用进程申请了多少资源.
    进程终止时, 内核会检验进程是否有尚未处理的信号量调整值, 如果有, 就会释放该资源.

    获取信号量的步骤,信号量包含一个表示资源数的值:

    1. 将值减去请求数,再检查值是否小于0。这个步骤是原子的。

    2. 如果值小于0,就将进程阻塞,加入到信号量对应的等待队列中(需要对队列加锁)。
      如果值大于等于0,表明进程获得了信号量,可以继续运行。

      释放信号量的步骤:

    3. 将值加上占用的资源数,再检查值是否小于0。这个步骤是原子的。

    4. 如果值小于0,表明还有进程在等待资源,就唤醒一个睡眠的进程。(那么如果可以唤醒多个进程呢?)
      如果值大于等于0,什么事也不做。

  • XSI共享内存

    要注意在多个进程间同步对共享内存的访问.

    共享内存和mmap的区别是前者不要求存在实际的文件.

    int shmget(key_t kry, size_t size, inmt flag) 用于获取共享存储的ID, 返回共享存储的标识符.

    void* shmat(int shmid, const void* addr, int flag) 用于将共享存储连接到进程的地址空间,
    返回值是共享存储在进程地址空间的首地址.

    int shmdt(const void *addr) 用于将共享存储与进程分离. 此时该IPC结构及其标识符仍然存在,
    直到某个进程显式删除之(用shmctl函数).

    shmget基于VFS, 会创建一个文件对象, 然后进程可以利用mmap将其映射到自己的内存空间.

  • POSIX信号量

    相比XSI信号量更加高效

  • 信号

    用于通知进程某个事件已经发生.

  • 套接字

线程

从 linux2.6 开始, glibc采用了新的线程库, NPTL(Native POSIX Threading Library).
内核仍然用 task_struct 结构保存线程信息, 并增加 tgid(thread group ID) 字段(也有pid字段).
创建新线程时, 仍然为其分配pid. 但是如果是主线程, 其 tgid 设为 pid. 如果不是主线程, 就将 tgid 设为所在进程在 pid.
因此获取进程信息时, 只要检查其 tgid 和 pid 是否相等, 就可以判断线程是否是主线程, 并获取其所在线程组的主线程.
参考linux线程实现机制(上).

线程的状态分为两种:

  • detached 状态: 线程被分离, 线程结束时其底层资源立即被回收, 也不能用pthread_join来等待其终止状态.
  • joinable 状态: 线程的终止状态会保存, 直到对该线程调用pthread_join.

线程间同步

  • 互斥锁

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

     

    int pthread_mutex_init(pthread_mutex_t *restrict mutex,

                        const pthread_mutexattr_t *restrict attr);

     

    int pthread_mutex_destroy(pthread_mutex_t *mutex);

     

    int pthread_mutex_lock(pthread_mutex_t *mutex);

     

    int pthread_mutex_trylock(pthread_mutex_t *mutex);

     

    int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex,

                                const struct timespec *restrict abstime); // abstime是绝对时间.

     

    int pthread_mutex_unlock(pthread_mutex_t *mutex);

  • 读写锁

    必须先对读写锁进行初始化, 最后必须用destory函数清理初始化时获得的资源.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;

     

    int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,

                            const pthread_rwlockattr_t *restrict attr);

     

    int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

     

    int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);

     

    int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);

     

    int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

     

    int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);

     

    int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);

     

    int pthread_rwlock_timedrdlock(pthread_rwlock_t *restrict rwlock,

                                const struct timespec *restrict abstime);

     

    int pthread_rwlock_timedwrlock(pthread_rwlock_t *restrict rwlock,

                                const struct timespec *restrict abstime); // abstime是绝对时间.

  • 自旋锁

    自旋锁是忙等, 未获得锁时不会陷入阻塞, 而是一直检测锁, 因此会一直占用CPU, 也因此效率更高.
    但是当线程因为时间片用完等原因被取消调度时, 线程进入休眠, 也就无法运行自旋锁,
    因此线程阻塞在自旋锁的时间可能会比预期长.

    自旋锁包含一个字段指示当前状态,为1时表示空闲,其他值表示已被占用,初始时为1。实现方式如下:
    获取自旋锁时:

    1. 将该字段与一个寄存器的值(值为0)交换, 该操作是原子的.

    2. 检查寄存器的值. 如果为0, 表明锁已经被占用. 则等待锁被释放.(此时还会更新一些其他字段.)
      如果为1, 表明锁是空闲的, 进程立即获得锁.

      释放自旋锁:

    3. 将该字段与一个寄存器的值(值为1)交换。

      1

      2

      3

      4

      5

      6

      7

      8

      9

      10

      // pshared 取值可以是 PTHREAD_PROCESS_PRIVATE 或 PTHREAD_PROCESS_SHARED.

      int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

       

      int pthread_spin_destroy(pthread_spinlock_t *lock);

       

      int pthread_spin_lock(pthread_spinlock_t *lock);

       

      int pthread_spin_trylock(pthread_spinlock_t *lock);

       

      int pthread_spin_unlock(pthread_spinlock_t *lock);

  • 条件变量

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

     

    int pthread_cond_init(pthread_cond_t *restrict cond,

                        const pthread_condattr_t *restrict attr);

     

    int pthread_cond_destroy(pthread_cond_t *cond);

     

    // 调用wait前需要先lock mutex.

    // wait 的行为可以分为几步: 先将线程放入条件变量的等待队列, 再unlock mutex. 当收到通知时,

    // 尝试lock mutex, 获得锁后就返回, 程序继续执行.

    // 最好在wait返回后再次检查条件是否满足.

    int pthread_cond_wait(pthread_cond_t *restrict cond,

                        pthread_mutex_t *restrict mutex);

     

    int pthread_cond_timedwait(pthread_cond_t *restrict cond,

                            pthread_mutex_t *restrict mutex,

                            const struct timespec *restrict abstime);

     

    int pthread_cond_signal(pthread_cond_t *cond);

     

    int pthread_cond_broadcast(pthread_cond_t *cond);

    使用例子:

    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

    pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

    pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

     

    int i = 0;

     

    void reader()

    {

        for(int i = 0; i < 10; ++i)

        {

            pthread_mutex_lock(&lock);

            while(i <= 0)

            {

                pthread_cond_wait(&cond, &lock);

            }

            // process read

            --i;

            pthread_mutex_unlock(&lock);

        }

    }

     

    void writer()

    {

        for(int i = 0; i < 10; ++i)

        {

            pthread_mutex_lock(&lock);

            // process write

            ++i;

            pthread_cond_signal(&cond);

            pthread_mutex_unlock(&lock);

        }

    }

  • 内存屏障

    1

    2

    3

    4

    5

    int pthread_barrier_init(pthread_barrier_t *restrict barrier,

                             const pthread_barrierattr_t *restrict attr,

                             unsigned count);

    int pthread_barrier_destroy(pthread_barrier_t *barrier);

    int pthread_barrier_wait(pthread_barrier_t *barrier);

Linux内核的同步方式

在内核中, 可能存在多个内核执行流, 因此需要同步对共享数据的访问, 特别是在多处理器系统中.

  • 原子操作: 操作绝不会被其他事件打断, 需要硬件的支持.

  • 信号量: 减1之后若信号量值小于0, 进程就阻塞在该信号量上, 否则可以运行, 进程释放信号信号量时对其加1.
    当信号量值大于负数时表明可以调度等待进程.

  • 读写信号量

  • 自旋锁

  • 大内核锁(big kernel lock, BLK): 利用自旋锁实现, 但是支持一个进程递归地获得锁,
    释放次数等于获取次数时锁才会被释放. 大内核锁用于保护整个内核, 因此内核中只有一个BLK.
    进程持有BLK时如果被取消调度, 其BLK也会被释放, 再次获得调度时, 会重新获得BLK.

  • 读写锁

  • 大读者锁(brlock, big reader lock): 比读写锁的性能更好, 读者可以快速地获得锁, 而写者获得锁的开销较大.
    实现机制是每个大读者锁都在所有CPU上有一个本地读者写者锁, 读者仅需要获取本地CPU的读者锁,
    而写者必须获得所有CPU上的锁.

  • 读-拷贝修改(read-copy update, RCU): 读写锁的高性能版本, 比大读者锁具有更好的扩展性和性能.
    对于被RCU保护的共享数据结构, 读者不需要获得任何锁就可以访问它, 但写者在访问它时首先拷贝一个副本,
    然后对副本进行修改, 最后使用一个回调机制在适当的时机把指向原来数据的指针重新指向新的被修改的数据,
    这个时机就是所有引用该数据的CPU都退出对共享数据的操作.

  • 顺序锁(seqlock): 允许读写操作同时进行, 不能同时进行多个写操作. 读操作期间如果发生写操作,
    需要重新读. 适合读写操作同时发生的概率比较小的情况.

优化和屏障

编译器和CPU为了效率会重排程序指令, 为了防止出现问题, 提供了屏障. 分为优化屏障内存屏障.

  • 优化屏障: 保证编译器不会混淆放在原语操作前和原语操作后的指令.
  • 内存屏障: 对CPU的指令重排做出限制, 分为读屏障, 写屏障, 读写屏障, 如下(来自知乎):
    • 读屏障作用于Invalidate queue, 每次cpu遇到这个指令都将自己积压已久的invalidate ack处理掉.
      具体就是使得对应的缓存失效, 这样自己再读的时候, 能保证读到最新的副本.
    • 写屏障作用于store buffer, 将处于store buffer中的写操作真正执行掉.
      具体就是向其他CPU发送invalidate cache 的消息, 写自己的独占缓存.
    • 全能型屏障这两件事都做.

系统编程

  • read, write: 返回-1时都表示出错. read 成功时返回读到的字节数, 当读到文件尾时, 返回0;
    write 成功时返回已写的字节数, write 成功返回仅仅表示数据写到了缓冲区.
    read和write在内核的实现基本类似, 下面介绍其步骤:

    1. 从 fd 获取文件对象地址. 每次调用 open 都会生成一个文件对象, 文件描述符表中的元素指向了对应的文件对象.
    2. 检查文件对象中的权限, 能否进行读/写. 权限不满足就返回错误.
    3. 检查文件对象是否提供了读/写操作, 没有就返回错误.
    4. 验证参数, 保证提供的内存地址空间(缓冲区)在用户地址空间.
    5. 检查文件锁是否冲突.
    6. 调用文件对象的读/写函数来进行实际的操作. 此时文件偏移将会被更新.
    7. 释放文件对象, 返回实际读/写的字节数.
  • creat: 等价于 open(path, O_WRONLY|O_CREAT|O_TRUCT, mode).

  • 下面的函数用于复制一个文件描述符, 返回一个值不同, 但是与旧的fd等价的文件描述符, 旧的fd仍然有效.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    // dup 返回当前可用的fd的最小值.

    int dup(int oldFd);

     

    // dup2 用 newFd 指定新的描述符的值, 返回新的的描述符的值.

    // 如果 newFd 已经打开, 就先将其关闭, 并且保证关闭和重新使用不会被打断, 是一个原子操作,

    // 因此不等价于先关闭 newFd, 再调用 dup.

    // 如果 newFd == odlFd, 程序不做任何事.

    // 除了上面两种情况, newFd 的 FD_CLOEXEC 标志都会被清除, 这样进程在 exec 后 newFd 仍然打开.

    int dup2(int oldFd, int newFd);

     

    // 除了下面两点, 等价于 dup2:

    // 1. 可以通过 flags 显式设置 FD_CLOEXEC 标志.

    // 2. 当 oldFd == newFd 时, 函数失败.

    int dup3(int oldFd, int newFd, int flags);

  • 内核设有缓冲区, 大多数磁盘IO都通过缓冲区进行. 当向文件写入时, 内核通常先将数据复制到缓冲区,
    然后排入队列, 晚些时候再写入磁盘. 这种方式称为延迟写(dealyed write).
    为了保证数据及时写入到磁盘磁盘, 提供了下面几个函数, 返回 0 时表示成功, 返回 -1 时表示失败:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    // 将所有修改过的块缓冲区排入写队列, 然后返回, 不等待实际写操作结束.

    void sync();

     

    // 类似于 sync, 但是只针对 fd 指定的文件.

    int syncfs(int fd);

     

    // 只针对 fd 指定的文件, 等待磁盘写操作结束才返回. 更新文件的数据和属性.

    int fsync(int fd);

     

    // 类似于 fsync, 但是只更新文件的数据.

    int fdatasync(int fd);

  • 获取时间的函数, time和gettimeofday都返回墙上时间, 并且当系统时间被更改时, 得到的值也会受影响.
    例如可能后面的命令返回的时间比前面的早, 因此不能用他们来测量一段时间的长度(例如操作花了多久),
    而应该使用 clock_gettime, 并将第一个参数设为 CLOCK_MONOTONIC.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    // 返回Unix时间, 参数不为NULL时也会写到对应变量中. 精确到秒.

    time_t time(time_t *calptr);

     

    // tzp 必须设为 NULL. 精确到毫秒.

    int gettimeofday(struct timeval *restrict tp, void *restrict tzp);

     

    // 获取指定时钟的时间, 写到 tsp 中. 成功返回 0, 否则返回 -1. 精确度可能更高, 最高到纳秒,

    // 前提是系统支持.

    int clock_gettime(clockid_t clock_id, struct timespec *tsp);

  • 程序执行C语言程序时, 在执行 main 函数之前, 会先执行一个特殊的启动例程, 用于从内核获得命令行参数和环境变量等,
    然后调用 main 函数. 一般是:

    1

    exit(main(argc, argv)); // 将 main 返回值作为 exit 参数.

  • 程序有 8 种终止方式, 其中 5 种是正常终止, 分别是:
    从main返回; 调用 exit; 调用 _exit或_Exit; 最后一个线程从其启动例程返回; 从最后一个线程调用 pthread_exit;
    3 种是异常终止, 分别是: 调用 abort; 接到一个信号; 最后一个线程对取消请求做出响应.

  • 不论进程如何终止, 最后都会执行一段相同的内核代码以关闭进程的打开文件描述符, 释放使用的存储器.

  • 几种退出函数:

    1

    2

    3

    4

    5

    6

    7

    8

    9

    // 等价于 return 语句, 会调用 _exit. 先执行清理操作, 再返回内核. 操作有:

    // 1. 调用终止处理程序(用atexit注册).

    // 2. 关闭IO流.(不一定)

    void exit(int status);

     

    // 下面两种都是直接返回内核, 但是属于进程的打开文件描述符都会被关闭.

    // 可能会清理IO流, 由实现决定.

    void _exit(int status);

    void _Exit(int status);

  • 下面的函数用于动态申请/释放内存, 其中 brk 和 sbrk 是系统调用, 用于改变进程堆的大小.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    // malloc, calloc, realloc 都是用 brk/sbrk 实现的.

     

    // 1. 当用 malloc 申请的内存超过一定大小时(MMAP_THRESHOLD, 一般是128kB), 会调用 mmap 来分配空间.

    // 2. 对于多线程环境, 为了减少多个线程申请内存时发生的竞争, malloc 维护多个堆区域, 称为 arena,

    // 不同线程在不同 arena 获取内存. 一般限制arena个数为核心数的8倍, 此限制同样可以修改.

    // 3. malloc 是线程安全的, 不是可重入的.

    // 前者是因为 malloc 的实现中采用了锁, 能保证不会发生冲突, 这样的实现也导致了后者.

    // 例如当执行 malloc 时, 如果接收到信号, 执行信号处理函数时又调用了 malloc, 就很可能会发生死锁, 因为 malloc 使用的锁是非嵌套的.

    // 如何避免这个问题呢: 要尽量简化信号处理函数, 不要在其中做太多工作. 例如可以简单的设置一个标志, 用户代码检测到标志时进行处理工作.

    // 4. 具体 glibc malloc 的实现可以参考: https://sourceware.org/glibc/wiki/MallocInternals

    void* malloc(size_t size);

    void* calloc(size_t nobj, size_t objsize);

    void* realloc(void *ptr, size_t new_size);

     

    // free的参数必须是malloc等函数的返回值, 不能对malloc的返回值增减之后再来调用free.

    void free(void *ptr);

     

    // brk 将进程的堆的地址增长到 addr 指定的位置. 成功返回 0, 否则返回 -1.

    int brk(void *addr);

     

    // sbrk 将进程的堆增长 increment bytes. 返回增长前堆的末尾的地址, 即增长的内存的起始地址,

    // 发生错误时返回 (void*)-1.

    void* sbrk(intptr_t increment);

  • 创建新的进程的接口, 都利用 _do_fork 实现, 在 linux/kernel/fork.c 文件中.
    Linux 在内核中为每个进程维护一个 task_struct 数据结构以保存进程的所有相关信息, 其中包含: 进程状态, pid, 打开文件描述符表指针等.
    linux 中线程的实现类似于进程, 同样会为每个维护一个 task_struct. 主线程的 task_struct 就是进程的 task_struct.
    可以通过 task_struct 中的 tgid 字段识别是否是主线程, 主线程的 tgid 与 pid 相等.

    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

    // 父进程的虚拟地址空间都会被复制, 包括锁等.

    // 为了提高效率, 并不立即复制父进程的数据段, 堆, 栈, 而是使用写时复制(COW).

    // fork 后子进程还是父进程先运行是不确定的.

    // IO缓冲区中的内容也会被复制到子进程中.

    // 父进程的所有打开文件描述符都被复制到子进程中, 父子进程共享文件对象, 看到的文件状态(如偏移)是一致的.

    //

    // fork后父子进程的区别包括:

    // 1. fork返回值, 进程id, 父进程id

    // 2. 子进程不继承父进程的文件锁.

    // 3. 子进程的未处理闹钟被清除, 未处理信号被设为空集.

    //

    // fork失败的原因一般是: 系统进程过多 or 实际用户的进程超过系统限制.

    //

    // fork得到的子进程是单线程的, 并从fork语句开始运行. 可以用 pthread_atfork 函数来处理多线程时资源的释放(如锁).

    // 父进程是多线程时, 子进程一般fork后就直接调用 execve.

    //

    // 内核可能倾向于让子进程先运行, 其好处是: 因为一般子进程会马上调用 exec 函数, 这样可以避免写时复制的开销.

    // 如果父进程先运行, 可能会在 fork 之后写地址空间, 引起写时复制, 而实际上这个复制很可能是不需要的,

    // 因为子进程不会用到这些地址空间.

    pid_t fork();

     

    // 不会将父进程的地址空间复制到子进程, 在子进程中应该立即调用 exec 或 _exit, 以避免引用父进程地址空间.

    // 在调用 exec 或 _exit 之前, 子进程运行在父进程的地址空间, 因此对变量的修改, 函数的调用会带来未知的结果.

    // vfork 保证子进程先运行, 只有当子进程调用 exec 或 _exit 后父进程才会继续运行.

    // 注意: 不能用 exit 代替 _exit, 前者会导致 exit handler 被调用, 并且会冲洗 io 缓冲区.

    pid_t vfork();

     

    // 创建进程, 执行 fn 指定的函数, arg 为传给函数的参数, child_stack 指定了进程的堆栈起始地址,

    // flags 指定创建的进程的相关属性. 线程的创建就是基于 clone.

    // clone 可以与父进程共享地址空间, clone 创建出的进程可以是子进程/兄弟进程.

    int clone(int (*fn)(void *), void *child_stack, int flags, void *arg, ... /* pid_t *ptid, void *newtls, pid_t *ctid */);

  • 当一个进程终止时, 内核会为其保存部分信息, 如进程ID, 终止状态等, 父进程可以通过 wait/waitpid来获取这些信息.

  • 一个进程的父进程已终止时, 内核将其父进程设为 init 进程(进程号是 1).

  • 僵尸进程: 一个已经终止, 但是父进程尚未对其进行善后处理(获取其状态, 释放占用的资源)的进程称为僵尸进程.

  • 非阻塞IO: 在 open 时指定 O_NONBLOCK 标志; 对于已经打开的文件, 可以用 fcntl 打开 O_NONBLOCK 标志.

    1

    2

    3

    oldFlag = fcntl(fd, F_GETFD, 0);

    oldFlag |= O_NONBLOCK;

    fcntl(fd, F_SETFD, oldFlag);

  • 记录锁(record locking): 对文件的一部分加锁, 以偏移量指定范围. 可以添加写锁或读锁.
    实现记录锁的方式有: fcntl, lockf, flock.
    系统通过进程 id 来记录进程对记录锁的占用. 因此:
    fork产生的子进程不会继承父进程的记录锁, 因为她有不同的进程ID.
    而exec后进程仍持有记录锁, 因为其进程ID未变.

  • flock: 进程退出或关闭fd时会自动取消此记录锁. 不同进程分别调用open等函数获得相同文件的fd时,
    记录锁仍然正常作用. 获得锁的进程再次调用flcok获取锁时, 新的锁会代替旧的锁.
    flock 只能锁住整个文件, 而不能针对部分操作.

  • wait, waitpid, waitid, wait3, wait4: 获取子进程的终止状态. wait 是阻塞的, waitpid 可以设为非阻塞.

  • exec 系列函数.

    第一个参数是 pathname 的表示可执行文件通过路径名指定, 是 file 的如果以 '/' 开头就看做绝对路径,
    否则按 PATH 环境变量搜索文件.
    其中 l/v 的区别在于传递给要执行程序的参数的形式, 前者是通过多个参数指定(list), 这要求最后一个参数是NULL.
    后者是通过一个二维数组指定, 一般将 argv[0] 设为可执行文件的文件名.
    以 e 结尾的函数向可执行程序传递环境变量.
    这些函数最终都是通过 execve 实现的.

    1

    2

    3

    4

    5

    6

    7

    int execl(const char *pathname, const char *arg0, ... /* (char *)NULL */);

    int execv(const char *pathname, char *const argv[]);

    int execle(const char *pathname, const char *arg0, ... /* (char *)NULL */, char *const envp[]);

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

    int execlp(const char *file, const char *arg0, ... /* (char *)NULL */);

    int execvp(const char *file, char *const argv[]);

    int fexecve(int fd, char *const argv[], char *const envp[]);

  • 线程控制函数. 一般线程终止时, 会保存部分状态信息, 直到其他线程对其调用 pthread_join.
    除非线程被分离.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    // 创建线程, 从函数 fn 开始执行. 不能保证新旧线程的运行顺序. 新线程的线程id保存在 tidp.

    int pthread_create(pthread_t * tidp, const pthread_attr_t * attr, void *(*fn)(void *), void *arg);

     

    // retval 是传给其他线程的参数, 其他线程可以通过 pthread_join 函数获得.

    void pthread_exit(void *retval);

     

    // 等待指定线程终止, retval 指定的内存将保存 pthread_exit 的参数.

    // 当目标线程被取消时, 该值被设为 PTHREAD_CANCELED.

    // 如果线程已被分离, 调用会失败.

    int pthread_join(pathread_t thread, void **retval);

     

    // 分离指定线程. 线程终止时底层资源会被立即释放, 不能对其调用 pthread_join.

    int pthread_detach(pthread_t thread);

  • aio_read, aio_write: 异步io的读写.

  • 存储映射IO

    mmap并不会立即将文件复制到内存中, 而是先分配虚拟地址空间, 并创建一个数据结构来维护这段虚拟地址空间.
    数据结构保存了这段的起始位置, 结束位置和其他相关参数. 多个mmap区域对应的数据结构被组织为一个链表.

    当程序访问该该段地址空间时, 产生一个缺页中断, 内核此时才会将文件对应页的数据复制到内存页.
    用 read 接口访问文件时, 需要先将文件复制到内核的内存空间, 再复制到用户的内存空间. 需要一次磁盘读取加一次内存复制.
    而 mmap 直接将数据从磁盘复制到用户内存空间, 只需要一次磁盘读取加一次缺页中断.

    1

    2

    3

    4

    5

    6

    7

    8

    9

    10

    11

    // 将 fd 和 offset 指定文件的部分映射到 addr 和 len 指定的内存区域,addr 可以设为 NULL,由系统决定放置位置。

    // prot 指定了对内存区域的访问权限。

    // 在映射之前必须保证文件已经打开,映射之后关闭 fd 不会导致映射区域关闭。

    // 对映射区域的写操作,由内核决定何时更新到磁盘。

    void* mmap(void* addr, size_t len, int prot, int flags, int fd, off_t offset);

     

    // 主动关闭映射区域。进程终止时也会关闭映射区域。关闭映射区域不会使得映射区域的内容写到磁盘。

    int munmap(void* addr, size_t len);

     

    // 将已修改的页更新到文件。

    int msync(void* addr, size_t len, int flags):

网络编程

  • 接收数据

    1

    2

    3

    4

    5

    6

    // 返回接收到的数据的字节数,没有可用数据或对方已经断开连接时返回0。失败时返回 -1。(TCP)

    ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);

     

    // 通常用于无连接的套接字(UDP)。

    ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,

                     struct sockaddr *src_addr, socklen_t *addrlen);

  • 发送数据

    1

    2

    3

    4

    5

    6

    // 成功时返回发送的字节数,注意这只是表示数据被写到了缓冲区,而不是发送到了目的地。错误时返回-1。(TCP)

    int send(int sockfd, const void *buf, size_t nbytes, int flags);

     

    // 用于对无连接的套接字发送数据(UDP)

    ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,

                   const struct sockaddr *dest_addr, socklen_t addrlen);

IO模型

同步IO和异步IO的区别在于进行IO时进程是否阻塞/是否能执行其他命令.
阻塞IO和非阻塞IO的区别在于当IO未就绪时是否等待, 阻塞/非阻塞IO都属于同步IO.

  1. 同步IO

    • 阻塞式I/O: 当读写操作不能开始时, 进程会被阻塞. 当读写操作完成时才会返回.

    • 非阻塞式I/O: 当请求的IO操作需要阻塞进程时, 不阻塞进程, 而是返回一个错误.
      以后进程可以不断发起读写请求.

    • I/O复用: 同时等待多个文件描述符.

    • 信号驱动式I/O: 首先进程注册一个信号处理函数, 随后当IO就绪时, 操作系统会发送SIGIO信号,
      通知进程可以进行IO. 此时进程的信号处理程序被调用, 进程可以在信号处理程序中进行IO,
      也可以通知主程序来进行IO.

  2. 异步I/O

    异步IO函数的机制是: 告知内核启动某个操作, 并让内核在整个操作完成后通知自己.
    信号驱动是可以进行IO时通知进程, 异步IO是IO操作完成后通知进程.

IO复用接口

select

等待多个文件描述符, 当任意一个可用时, select 就返回, 否则一直阻塞. 函数返回时, 如果不是因为超时,
可以通过遍历文件描述符来找到就绪的描述符(用 FD_ISSET 检测文件描述符).

select 对等待的文件描述符数量有限制.

函数参数为: 三个集合大小中的最大值加一, 等待读的文件描述符集合, 等待写的文件描述符集合,
等待异常的文件描述符集合, 最长等待时间. 返回值是就绪的文件描述符数量.

poll

同样是等待多个文件描述符, 当一个就绪或超时时就返回, 需要遍历 fd 来获得就绪的 fd.

poll没有等待的数量的限制, 但是过多时性能会下降.

通过pollfd集合来指定文件描述符.

epoll

首先建立一个epoll句柄, 内核此时会建立一个数据结构来维护要等待的文件描述符及其事件.

然后通过epoll_ctl增删改等待的文件描述符及其事件.

最后通过epoll_wait来等待文件描述符就绪.

epoll有两种工作模式: 水平触发(level trigger)和边沿触发(edge trigger).
水平触发是默认的工作模式, 指当IO就绪时, epoll_wait 检测到事件后, 如果不处理, 事件不会立刻销毁,
下次调用 epoll_wait 时仍然可以检测到此事件.
边沿触发则不同, 当 epoll_wait 检测到事件后, 就会将事件销毁, 如果不处理, 下次 epoll_wait 就不能检测到此事件.

因此当采用边沿触发时, 如果读到的数据与请求的大小相同, 很可能还有数据没有读完. 因此此时要采用非阻塞读,
直到返回的数据少于请求的数据或返回EAGAIN错误.

epoll_wait 可以直接得到就绪的文件描述符以及对应的事件, 因此不需要扫描所有文件描述符.

epoll 相比 poll 和 select 的另一个优势是不需要在每次调用时都将文件描述符集合复制到内核空间.

epoll 的最大 fd 数等于最大的可打开文件数.

IO复用的实现原理

IO设备的驱动程序中有一个等待队列, 可以通过驱动程序提供的 poll 接口来获取IO设备是否就绪, 也可以将进程加入到等待对应的等待队列中.
当IO设备就绪时, 就可以通知等待队列中的进程, 将其从睡眠中唤醒.
select, poll 的实现是:

  1. 扫描所有 fd, 利用 poll 接口检测对应的IO是否就绪, 并将进程加入到等待队列.
  2. 如果存在就绪的 fd, 就在遍历后返回就绪 fd 的数量.
  3. 如果没有就绪的 fd, 就睡眠一段时间.
  4. 如果休眠结束或被IO驱动程序唤醒, 继续循环上面的过程. 如果是后者, 就会检测到就绪的 fd, 从而可以在第二步返回.

epoll 流程也类似上面, 只有下面几点不同:

  • epoll 在创建句柄时将 fd 集合拷贝到内核, epoll_wait 时不需要再拷贝 fd 集合, 这样对同一集合多次调用 epoll_wait 时就只需要拷贝一次.
  • epoll 的句柄通过一个红黑树来维护 fd 集合, 每次加入时会先在红黑树中查找是否已经保存了该 fd,
    时间复杂度是 log(N)log(N).
  • epoll 第一次也要遍历 fd, 并将进程加入到等待队列, 但是同时为每个 fd 指定了回调函数, 当 fd 就绪时,
    设备驱动程序会唤醒进程, 并调用此回调函数. 这个回调函数的作用是将这个就绪的 fd 加入到就绪链表.
    因此, 当进程从等待中被唤醒时, 就可以直接通过检查这个就绪链表是否为空来判断是否有 fd 就绪,
    而不需要像 select/poll 一样再次遍历 fd 集合.
  • 调用 epoll_wait 时, 会把就绪的 fd 拷贝到用户态内存, 然后清空就绪链表, 最后再检查这些 fd.
    在水平触发模式下, 如果检查到这些 fd 上还有未处理的事件, 会将这些 fd 放回就绪链表中, 保证事件得到正确处理.
  • 对于 fd 集合大小的限制, epoll 是进程可以打开的最大文件数目, 这个值保存在 /proc/sys/fs/file-max.

参考
select()/poll() 的内核实现,
select,poll,epoll实现分析—结合内核源代码.

select, poll, epoll的应用场景

并不是说 epoll 一定要比 select 和 poll 要好, 例如由于 epoll 在设备驱动程序注册的回调函数,
每次对应的 fd 就绪时, 都会在内核空间因此一次额外的函数调用, 将对应 fd 插入到就绪集合中.
当大都是短连接, 或大都是发送少量数据时, 带来的开销可能比可能可能比 poll 更大.

socket读写

socket 函数用于创建套接字(socket), bind 函数可以将套接字绑定到本地的一个地址(ip:port),
不为socket指定地址时, 在对socket调用 connect 或 listen 时, 内核会自动为socket选择一个地址.

客户端建立连接是的函数调用过程: socket(), bind()(可省略), connect(), send()/write().

服务端处理连接的函数调用过程: socket(), bind(), listen(), accept(), recv()/read().

  • TCP通信

    connect 函数用于向指定地址发起连接(客户端), listen 函数用于向内核声明scoket可以接受连接(服务端),
    用 accept 函数即可获取连接.

    当调用 connect 函数时, 客户端发出 SYN 数据包. 服务端如果已经调用了 listen, 就会对此回复 ACK+SYN,
    connect 函数接收到此回复后, 再回复 ACK, connect 函数即可返回. 此时一个连接就已经建立.

    随后服务端可以调用 accept 函数, 获取对应的文件描述符, 调用 read 函数和 write 函数来读写数据,
    二者都是返回已经读/写的比特数. 注意 write 函数返回时只是将数据写到了内核缓冲区, 并不能保证已经发送出去了.

    recv 函数和 send 函数专门用于向套接字读/写数据, 但是相比 read 和 write, 提供了额外的一个参数,
    当参数为0时等价.

    close 函数将会把一个套接字标记为关闭, 并立即返回. 之后调用者不能再向该套接字读写数据.
    但是此时缓冲区中可能还有数据, 内核将会将排队等待的数据发送到对端, 发送完毕之后才会执行的TCP连接关闭操作.

    调用 close 函数后, 对端的读操作将会返回一个 EOF.

    套接字描述符同文件描述符一样, 也维护了引用计数, 只有当引用计数为0时, 才会真正执行TCP挥手过程.

    int shutdown(int sockfd, int howto) 函数则会直接关闭套接字描述符, 不管其引用计数有多少.
    并且 howto 参数可以控制是关闭连接的读, 写, 读+写.

  • UDP通信

    UDP通信不需要建立连接, 可以直接向指定地址读/写数据, 函数是:

    1

    2

    3

    4

    ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,

                    struct sockaddr *src_addr, socklen_t *addrlen);

    ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,

                const struct sockaddr *dest_addr, socklen_t addrlen);

    对于 sendto 函数, 如果已经对sockfd调用 connect 建立了连接(是的, 即使是UDP也可以调用 connect),
    dest_addr参数和addrlen参数会被忽略.

    对于 recvfrom 函数, src_addr参数用于保存数据发送者的地址, addrlen参数说明了地址参数的长度.

    UDP通信的过程一般是:

    客户端和服务端都用 socket 函数创建套接字, 其中服务端还要用 bind 函数将套接字绑定到一个地址.
    客户端调用 sendto 函数向指定地址发送数据, 数据写到缓冲区时 sendto 函数返回.
    服务端用 recvfrom 函数接收数据, 返回读取到的字节数.
    此时一次数据发送就完成了. 服务端可以再用 sendto 函数回复数据, 客户端用 recvfrom 函数接收数据.

一个数据包是如何被接收的呢? 下面描述一下操作系统接收数据包的过程:

  1. 数据包到达, 产生中断.
  2. 检测设备.
  3. 接收链路层包头.
  4. 为数据包分配空间.
  5. 通知总线将数据包放到缓存.
  6. 将数据包放到 backlog 队列(累积队列).
  7. 设置标志以运行"网络调度器".
  8. 返回到进程.

TCP接收一个数据包的过程为:

  1. 检测seq和标志, 如果合法就将数据包保存.
  2. 如果数据包之前已经收到, 就立即返回ACK并丢弃数据包.
  3. 确定此数据包属于哪一个socket.
  4. 将数据包保存到对应socket的接收队列.
  5. 唤醒等待接收此socket数据的进程.

常见网络编程接口的实现

  1. socket

  2. bind

  3. connect

  4. listen

    listen 使一个套接字从默认的主动套接字转为被动套接字, 即可以响应外来连接, 当客户端对其调用 connect,
    被动套接字会自动与客户端进行三次握手. 内核为一个监听套接字维护两个队列:

    • 未完成连接队列(incomplete connection queue), 当第一个SYN数据包到达, 还未完成三次握手时,
      客户端对应的数据保存在此队列中.

    • 已完成连接队列(completed connection queue), 完成三次握手后(即收到客户端的ACK),
      会将客户端对应的数据转移到此队列.

      listen 函数的第二个参数(backlog)定义了未完成连接队列的最大长度. 可能出现这样的问题:
      客户端发送了第一个SYN之后就当机了, 因此无法发送最后一个ACK, 此时该客户端对应的数据结构会永远保存在未完成队列中.
      因此规定了一个数据在未完成队列中的最长时间, 超过此时间就会被移除.

  5. accept

    从已完成队列的头部获取一个连接.

  6. write/send

  7. read/recv

文件系统

文件

文件类型分为下面几种:

  • 普通文件
  • 目录文件
  • 设备文件: 分为下面两种. 设备文件都有主设备号和次设备号, 前者表明设备类型, 说明设备应该使用的驱动程序,
    后者指明具体的设备.
    • 块特殊文件: 设备文件的一种, 支持随机访问, 提供缓冲. 如硬盘.
    • 字符特殊文件: 设备文件的一种, 一般不支持随机访问, 不提供缓冲.
  • FIFO
  • 套接字
  • 符号链接

文件权限位有 set-group-id 位 和 set-user-id 位, 其作用是当执行此文件时,将进程的有效用户/组id设为文件的所有者的id/组所有者的id。

进程创建一个新文件时,文件所有者为进程的有效用户,而文件的组所有者为进程有效组或所在目录的组。

如果对目录设置了粘着位(saved-text bit), 只有对该目录有写权限并且满足下面某一个条件,
才能删除或重命名目录下的文件.(在rwx形式的文件权限表示中在最后, 用t表示)

  • 拥有此文件
  • 拥有此目录
  • 是root

文件权限的含义, 特别是目录文件.

新创建的文件的属主ID为创建文件进程的有效用户ID.
组ID默认是进程的有效组ID, 若新文件所在目录设置了 set-group-ID 位, 则组ID是目录组ID.

VFS

linux可以使用不同的文件系统, 其实现是在用户和文件系统中增加了一层虚拟文件系统(VFS), 以此引入一个通用的文件模型.
因此, VFS(或者Linux内核)不能对一个诸如read, write这样的函数进行硬编码, 因为不同的文件系统需要不同的实现方式.
所以, Linux要求每个文件系统实现自己这些函数, 然后提供一个操作列表, 在用户通过VFS进行系统调用时,
内核检查文件是否实现了对应的函数, 如果支持, 就调用该函数来执行实际的操作.

进程打开文件如何关联到磁盘文件

内核为每个进程维护一个文件描述符表, 表项记录了对应 fd 的文件指针, 他指向一个文件对象. fd 实际上是表项的下标.

文件对象是内核维护的, 记录了所有进程打开的文件(不同进程打开同一文件对应不同文件对象),
其中包含文件状态标志, 当前偏移量, 文件对应v节点指针. v节点指针指向一个v节点表项.
当关闭(如调用 close)打开的文件描述符时, 会将文件描述符表中对应的项置为无效, 并删除对应的文件对象.

fork产生的子进程有独立的文件描述符表, 但是文件指针的值与父进程的相同. 即子进程与父进程共享同一个文件对象,
此时如果在子/父进程中关闭对应的文件描述符, 并不会立即删除对应的文件对象, 父/子进程中还能继续使用该文件.
这是因为文件对象维护了一个引用计数, 只有当计数为0时才会释放该文件.

v 节点表项是Linux虚拟文件系统建立的, 用于维护通用的文件结构. 包含一个i节点指针, 指向对应的i节点.
系统中每个文件只对应一个 v 节点.

i节点包含文件的具体信息, 如长度, 数据所在磁盘位置等. i 节点是文件系统相关的.

Linux中用通用i节点替代 v节点.

中断

中断可以导致系统从用户态转为内核态, 分为三种:

  • 用户程序调用系统调用引起的软件中断
  • 由外部设备产生的硬件中断
  • 代码执行出错(如除0)导致的异常

软件中断

linux中的每个系统调用都有一个系统调用号, 用户进程通过系统调用号来通知系统调用哪个系统调用.
一旦系统调用号被分配, 就不能再分配给其他系统调用. 有时候某些系统调用会被取消, 此时这些系统调用号就无效了.
linux专门定义了一个系统调用, 这些无效的系统调用号都对应该系统调用, 该系统调用只是简单的返回 -ENOSYS,
表明系统调用无效, 不做其他工作.

用户进程调用系统调用时, 会触发一个软件中断, 一般是第128号软中断. 这个中断会使得系统转到内核态,
并执行对应的中断处理程序 system_call(). system_call() 根据对应用户给的系统调用号调用对应的系统调用,
在调用返回后, 就切换回用户空间, 让用户进程继续执行.

硬件中断

由硬件产生, CPU收到硬件中断后, 就会通知操作系统. 每个硬件中断有一个对应的中断号(例如 1 是键盘中断),
并且对应一个中断处理程序(interrupt handler, 也叫中断服务例程, interrupt service routine, ISR),
设备的中断处理程序是设备驱动的一部分.

一般将中断处理程序分为前后两部分(top half, bottom half), 在执行前面一部分(top half)时会禁止所有中断,
做一些有严格时限的工作, 此时ISR的执行不会被其他中断打断. 在执行后一部分(bottom half)时则可以允许其他中断, 允许被打断.

在早期的linux中, ISR是在被打断进程的内核栈运行的(总会有一个进程在运行, 即使没有进程可以调度, 也会运行一个定义的空任务),
而在2.6之后, 每个处理器拥有一个中断栈(一般是一页)专门用于执行中断处理程序.

中断的处理流程为:

  1. 硬件终中断到达中断控制器, 后者检查该中断是否被屏蔽.
  2. 中断到达CPU.
  3. 系统跳转到一个预定义的内存位置, 执行预定义的指令.
  4. 屏蔽相同的中断, 检查是否有对应的ISR.
  5. 如果有执行对应的ISR, 如果没有就直接转到下一步.
  6. 处理善后工作, 例如检查是否需要调用 schedule(), 返回被中断处.

异常

其他

  • 用户态和内核态

    执行用户地址空间的指令时, 称处于用户态. 当执行内核指令时, 处于内核态. 用户态只能执行非特权指令,
    需要通过转入内核态, 由内核来执行特权指令.

    系统调用, 异常, 外围设备中断会导致系统进入内核态.

    系统调用: 例如内存分配, 标准库中的 malloc 就是对系统调用 sbrk 的封装. 执行系统调用时会转入内核态.

    异常: 例如缺页异常. 发生异常时, CPU会暂停当前程序, 转入内核态, 去执行中断信号处理程序.

    外围设备中断: 由外围设备向CPU发出中断信号, 此时CPU会暂停当前程序, 转入内核态, 去执行中断信号处理程序.

    从用户态转入内核态时, 系统需要先保存进程上下文, 切换内核堆栈, 更新寄存器, 将权限修改为0级(特权级, 即进入内核态), 转而去执行对应的指令.
    恢复到用户态时, 通过保存的上下文更新堆栈, 寄存器等, 继续执行指令.

  • Linux开机启动

问题

  • 多进程的TCP服务端,能否互换fork()与accept()的位置?

  • 多个进程/线程能否绑定到同一端口?

    可以. Linux提供了 SO_REUSEPORT option, 调用 setsocketopt 函数设置套接字的 option 即可.
    所有监听该端口的进程/线程都需要设置 SO_REUSEPORT, 为了保证安全性, 第一个bind进程后的所有进程必须与第一个进程具有相同的 effective uid.
    多个进程/线程绑定到同一端口后, 如果对其调用accept, 这些进程会阻塞在相同的阻塞队列中.
    此时如果有一个connect请求进来, 内核会唤醒其中的一个进程/线程. 这能在一定程度上实现负载均衡.

    值得一提的是: 连接是通过client和server的 ip+port 来确定的, 假设server设置了 SO_REUSEPORT,
    如果client端的ip, port也都相同, 那么所有的数据包都会发送给对应的进程.

    关于该 option 的具体讨论可以参考 LWN: The SO_REUSEPORT socket option.

    另一个值得一提的是 SO_REUSERADDR option. 传统的网络服务器使用的就是此 option 来实现: 一个进程负责 accpet,
    accept 之后, 创建一个子进程来负责具体的任务. SO_REUSERADDR 的具体作用为:

    • 即使一个端口上存在连接(即正在被使用), 也允许一个进程/线程(后面都简写为进程) bind, listen 该端口.
      这是处理这样的问题: 当负责监听, 处理连接的进程崩溃时, 需要重启, 再调用 socket, bind, listen,
      此时连接已经存在, 默认会使得 bind 失败, 因此指定此 option.
    • 允许在同一端口上启动多个进程, 只要这些进程绑定到不同的本地ip地址, 这对于多宿主主机用处很大.
    • 允许单个进程绑定同一端口到多个套接字上, 只要不同套接字具有不同的本地ip地址.
  • gdb 的原理

    gdb 主要通过系统调用 ptrace 实现. ptrace 可以将 gdb attach 到指定线程, 这有两种方式:

    • 在 gdb 中运行一个进程, 再对其进行调试. 首先利用 fork 创建该进程, 再在子进程中调用 ptrace,
      并将第一个参数设为 PTRACE_TRACEME, 表示此进程将被父进程跟踪, 然后执行 exec 函数.

    • 将 gdb attach 到一个已存在的进程. 指定进程的 pid, gdb 调用 ptrace, 将第一个参数设为 PTRACE_ATTACH,
      将 pid 作为函数参数. 这样, gdb 就成为该进程的父进程, 并跟踪该进程.

      通过这两种方式, gdb 就和被调试进程建立了联系, 即成为其父进程, 该进程被父进程跟踪.
      此时任何传递给该进程的信号(除了SIGKILL)都将通过 wait 方法阻塞该进程, 并将信号转交给父进程.
      并且, 该进程如果调用 exec 函数, 都会接收到一个 SIGTRAP 信号, 使得父进程(gdb)可以在被跟踪进程执行第一条指令前就可以做一些需要的工作.
      这也是第一种实现方式的原理.

    • 断点*是通过内核信号实现的. 增加断点时, 实际是将指定位置写入指令 INT 3. 运行到此指令时,
      会触发 SIGTRAP 信号, 从而被跟踪进程会被暂停, gdb可以捕获到此信号. gdb将断点组织为一个链表,
      此时就可以查询此链表, 检查是否有匹配的断点记录, 当存在时就发生断点命中, 就允许用户做一些调试操作.
      否则继续执行命令.

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值