Linux 系统调用

内核提供了用户进程和内核进行交互的一组接口,这些接口在应用程序和内核之间扮演了使者的角色,应用程序发出各种请求,而内核负责满足这些请求,而这些接口,即是 系统调用

作用

系统调用主要起到保护系统稳定可靠运行的作用,避免应用程序肆意妄为。

除此,系统调用还起到了为用户空间进程与硬件设备之间添加一个中间层的作用,该层的主要作用三个:

  1. 为用户空间提供一种硬件的抽象接口,使用户进程不用区分硬件类型
  2. 使得内核可以基于权限、用户累心和其他一些规则对需要进行的访问进行判断
  3. 为用户空间和系统的其余部分提供公共接口

工作原理

一般情况下,应用程序通过在用户空间实现的应用编程接口(API)而不是直接通过系统调用来编程。在 Unix 中,最流行的应用编程接口是基于 POSIX 标准的,所以 Liunx的系统调用像绝大多数 Unix 一样,作为 C 库的一部分提供:

关于 Unix 的接口设计中有一句格言:“提供机制而不是策略”。换句话说,Unix 的系统调用抽象出了用于完成某种确定的目的的函数,至于这些函数怎么用完全不需要被内核关心。

要访问系统调用(在 Linux 被称为 syscall),通常通过 C 库中定义的函数调用来进行。通常系统调用可以定义零个到多个参数,也会返回一个 long 来标记执行情况或者遇到的错误,而具体的错误信息会写在 error 全局变量中,通过 perror 库函数便可以打印出可读的错误信息。

在 Linux 中每个系统调用都被赋予一个系统调用号(存储在 sys_call_table 中),这样通过独一无二的编号就可以关联系统调用使用户进程不需要提及系统调用的名称。

Linux 系统的系统调用比其他操作系统执行要快的多,首先是因为 Linux 很短的上下文切换时间,其次就是 Linux 的系统调用本身就被设计的非常简洁。

用户空间进程通过系统调用来 “执行” 内核代码,其通知内核的机制是通过软中断实现的: 通过引发一个异常来促使系统切换到内核态去执行异常处理程序 (用户空间引发异常陷入内核)。

在使用系统调用时,除了调用号以外,一般还需要一些外部的参数输入,所以在陷入内核的时候,需要把这些参数从用户空间传入到内核,而最简单的方法就是像传递系统调用号一样,把这些参数也存放在寄存器中。

2018/7/8 posted in  Kernel

Linux 进程调度

在参与面试的时候,Linux 的进程应该算是我的必问题目,因为很多语言并没有去实现一套进程管理和调度,而是直接借用了操作系统的能力,尤其 Python 社区认为这方面没必要重复造轮子,而使用了系统调用的简单封装。因此,如果对 Linux 进程不甚了解,很难真的了解自己写的并发代码到底会发生什么。

调度程序是内核中确保进程能有效工作的子系统,负责决定将哪个进程投入运行,何时运行以及运行多少时间。调度程序的合理调度,是将系统资源最大限度发挥的保证。

从 1991 年 Linux 的初版直到 2.4 版本的内核,Linux 的调度程序都相当简陋,设计非常原始。但在 2.5 的内核中,调度程序采用了一种叫做 O(1) 的调度程序(因为其调度算法的时间复杂度为 O(1))。但是 O(1) 调度程序对时间敏感、需要用户交互的程序先天不足,表现欠佳(也许因为这个原因早期 Linux 桌面版推进缓慢),在 2.6 内核的开发时便特意对交互类程序优化,并引入了新的调度算法,其中最著名的即是 翻转楼梯最后期限调度算法(Rotating Staircase Deadline scheduler,RSDL)。直到 2.6.23 内核中,该算法彻底地替换掉了 O(1) 算法,并被称为 完全公平调度算法,即 CFS。本文将重点介绍 O(1) 算法和 CFS 算法,并简单讲下 CFS 是如何实现的。

多任务与调度基本概念

Linux 能同时并发的交互执行多个进程的多任务操作系统。在多核处理器机器上,多任务操作系统能使多个进程在不同处理器上真正的并行执行,而在单核处理器机器上,只是产生多个进程同时执行的幻觉。无论是单核还是多核,操作系统都能使得多个进程处于阻塞或者睡眠状态,只将适合执行的进程交给处理器执行。

多任务系统一般分为 非抢占式多任务抢占式多任务。而 Linux 属于后者,也就是 由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行机会 。其中,这个强制挂起的动作,即是抢占(preemption),而进程在被抢占之前能够运行的时间是预先设置好的,而且有一个专门的名字,叫进程的时间片(timeslice)。

时间片是实际分给每个 可运行进程 的处理器时间段,很多操作系统都采用了动态时间片计算方式,也就是说分给进程的时间片具体是多少绝对时间是根据机器的负载动态计算的,而不是指定完就不再变化的。不过,Linux 的调度程序本身并没有通过分配时间片来达到公平的调度。

调度程序的精髓在于调度算法,算法策略决定调度程序在何时让什么进程执行,那么我们先提几个实际的问题来理解调度算法要做的事情。

IO 密集型和 CPU 密集型

进程可以被分为 IO 密集型和 CPU 密集型。前者的大部分时间用来提交 IO 请求或者等待 IO 请求,这样的进程经常处于运行的状态,但是通常每次都会运行短短的一会儿,因为它在等待更多的 IO 请求时最后总会阻塞。相反,CPU 密集型把时间大多用在执行代码上,除非被抢占,否则会一直执行下去,因为它们没有太大的 IO 请求或者没有太多可能被阻塞。而操作系统为了响应速度的考虑,调度器并不会让它们经常运行。

因为系统需要考虑到响应速度,调度器只需要保证不让 CPU 密集型长时间运行就可以,但有些程序不完全符合这两种分类,比如 Word 之类的办公软件,经常等待键盘输入(等待 IO),而在任意时刻,又可能粘住处理器疯狂的处理拼写检查和宏计算。

怎么让调度策略在两个矛盾的目标中间寻找平衡:响应迅速和最大系统利用率。不同的系统有不同的解决策略,但通常是一套复杂的算法,但大部分算法都无法保证低优先级的进程能被公平对待,而 Linux 的 CFS 基本解决这个问题(进程数量不会巨大的情况下)。

进程优先级问题

调度算法中最基本的一类就是基于优先级的调度,这是一种根据进程的价值和其他对处理器需求分级的想法。而一般分为两种,一种是优先级高的先运行,低的后运行,相同优先级的进程按轮转的方式进行调度。另一种是优先级高的进程使用的时间片比较长,调度程序总选择时间片未用尽而且优先级最高的进程运行。

Linux 采用了两种不同的优先级范围,第一种是用 nice 值,它的范围是 -20 ~ +19,默认为 0,而且 nice 值越高优先级越低。相对低优先级(高 nice 值)的进程,高优先级(低 nice 值)的进程可以获得更多的处理器时间。但对这个时间的分配,各个系统采用不同的方法,比如 macOS 采用了时间片的绝对值,而 Linux 中使用时间片的比例。

第二种范围是实时优先级,默认值为 0 ~ 99,与 nice 值相反,实时优先级数值越高,优先级越高,任何实时进程的优先级都高于普通进程,也就是说,这两个优先级范围是独立不相交的。而实时进程是 Linux 为有时间有严格要求进程的优待。

时间片

时间片是一个数值,它表明进程在被抢占前所能持续运行的时间。调度策略都会规定一个默认的时间片,但对于默认时间片的确定并不简单,如果时间片太长,往往就会延迟进程切换,使得系统对交互的响应表现欠佳,如果时间片太短,会明显增大进程切换带来的处理器耗时。

所以,任何时长的时间片都会导致系统表现欠佳,很多操作系统都非常重视这点,因此时间片默认一个比较适中的大小,比如 10ms。在 Linux 中 CFS 并没有使用绝对长度,而是将 CPU 的使用 时间比 分配给了进程,这样一来,Linux 中的进程获得的处理器时间和机器负载密切相关。

Linux 中进程获得的比例还会受到 nice 值影响,从而体现了优先级的作用。在多重条件影响下,内核就可以以时间片为依据来决定进程的运行:如果新的进程消耗的使用比当前执行的进程小,这立马剥夺当前进程的执行权,并把新进程投入运行。

需要用户交互的进程

前面提到,用户交互的进程更注重实时性,因此处理起来非常特别,我们举一个实际的例子来说明 Linux 是怎么处理需要用户交互的进程的。

如果有一个非常消耗 CPU 的进程,比如编译一个巨大的 C 程序,以及一个非常注重交互的记事本等类似的文字处理程序。对于编译进程来说,我们并不在意什么时候运行,早半秒还是晚半秒对我们来说没有差别,但是如果能尽快完成就再好不过了,而对于记事本进程,我们更希望在敲击键盘后立马看到反馈(立马抢占编译进程)。

对于实现上述需求依靠的是系统对记事本分配更高的优先级和更多的时间片(可能涉及到系统自动识别记事本程序需要更高的优先级),但在 Linux 中却采用了非常简单的方式,甚至不需要分配不同的优先级。如果两个进程 nice 值相同,那么这两个进程会分别各获得 50% 的 CPU 时间(Linux 下的时间片是个比例)。因为记事本不停的等待用户的输入,无疑大部分时间将是编译进程在执行,因此,记事本肯定用不到 CPU 的 50% 而编译程序肯定超过 50%。当用户输入的时候,会发现刚被唤醒的记事本使用比小于正在执行的编译进程,便立马抢占,从而快速响应。在记事本完成工作继续等待用户输入时,会被挂起将执行权再次分配给编译进程。

调度算法

上文通过一些简单的概念来描述了调度器做了什么,下面就开始正式讨论调度算法。

调度器类

在讲调度算法之前,需要先说明调度器类。Linux 调度器是以模块的方式提供的,这是为了允许不同类型的进程可以有针对性的选择调度算法。而提供不同调度算法的模块即是调度器类。像 CFS 就是一个针对普通进程的调度器类(定义在 kernel/sched_fair.c 中),在 Linux 中称为 SCHED_NORMAL(在 POSIX 中称为 SCHED_OTHER)。

每个调度器都有一个优先级,内核会先选择优先级最高的调度器,然后由该调度器调度进程并执行。

O(1) 调度算法

在讲 CFS 之前,先介绍下传统的 Unix 调度算法:O(1) 调度算法。现代进程调度器有两个通用的概念: 进程优先级和时间片 ,时间片是指进程运行多少时间,进程创建之后就被赋予一个时间片,优先级更高的进程运行的更频繁,而且往往拥有更多的时间片,这就是 O(1) 调度算法的实质。

很明显,除了让优先级更高的进程可以尽可能抢占之外,O(1) 调度算法还根据优先级来给时间片加权。但是,前面提到,传统的调度算法使用的 绝对的时间长度,这也引起了部分问题,比如有两个不同优先级的进程,一个 nice 值为 0,另一个为 1,那么他们经过加权的时间片长度分别是 100ms 和 95ms,他们的时间片非常接近,但是如果将 nice 值改为 18 和 19,这时他们的时间片变为了 10ms 和 5 ms,这时前者是后者两倍的运行时间,因此,尽管 nice 值只相差 1 但最后的结果却是差别巨大。

因此 CFS 完全摒弃时间片的绝对分配,而是分配处理器的使用比重。

CFS 调度算法

CFS 的出发点基于一个简单的理念: 进程调度的效果应该如同系统具备一个理想中的完美多任务处理器 。在这种系统中,每个进程将获得 1/n 的处理器时间(如果有 n 个进程)。比如我们有两个可运行进程,先运行其中一个 5ms,然后再运行另外一个进程 5ms,如果进程切换够快,那么在 10ms 内仿佛可以同时运行两个进程而且各自使用了处理器一半的能力。

当然这并不现实,首先一个处理器无法真正的同时运行多个任务,而且进程间切换是有损耗的,也无法做到无限快的切换,CFS 采用了折中的做法:让每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法。 CFS 在所有可以运行的进程总数基础上计算出一个进程应该运行多久,而不是依靠 nice 值来计算时间片(nice 值只影响比重而不是绝对值)。

每个进程都按其权重在全部可运行进程中所占比例的 “时间片” 来运行,为了准确的计算时间片,CFS 为完美多任务中的无限小调度周期的近似值设定了一个目标,称为:目标延迟。越小的调度周期带来越好的交互性,同时也越接近完美的多任务(但同时需要更多的切换开销)。举例我们将目标延迟定位 20ms,那么如果有两个同优先级的进程,那么每个进程在被抢占前只能运行 10ms,而如果有 5 个这样的任务,那每个任务只能允许 4ms。

但是,上面这个例子中,如果进程数目非常多,比如超过 20 个,那么每个进程获得运行时间还不到 1ms,甚至可能小于进程切换所消耗的时间。当然,Linux 为了避免这种事情发生,设定了一个底线,被称为最小粒度(一般默认 1ms)。因此,只能说 CFS 在进程数目不巨大的情况下比较公平(一般系统中也就运行几百个进程,这种规模下 CFS 还是非常公平的)。而对于不同优先级的进程中,CFS 也是表现良好,比如目标延迟依然为 20ms,这两个进程一个 nice 为 0,另一个为 5,那么后者的权重将是前者的 1/3,即两个进程分别获得了 15ms(20 * 2/3) 和 5ms(20 * 1/3) 的处理器时间。而如果两个进程的 nice 值分别为 10 和 15,因为权重关系没有改变,因此两个进程依然分别获得 15ms 和 5ms 的处理器时间。所以,nice 值 不在影响调度决策,只有相对值才会影响处理器时间的分配比例

在 CFS 下,任何进程所获得的处理器时间是由它自己和其他所有可运行进程 nice 值的相对差值决定的。nice 值由算数加权变为了几何加权,正是将时间片的绝对值变为了使用比,使得在多进程环境下有了更低的调度延迟。

CFS 的实现

在讨论了种种概念之后,终于到了重点,即 CFS 算法的实现。相关的代码在 kernel/sched_fair.c 中,我们的关注点主要下面四个地方:

  • 运行时间的记录
  • 选择投入运行的进程
  • 调度器的选择
  • 睡眠与唤醒

运行时间的记录

上文讲到,CFS 的核心在于 CPU 的使用比,那么对于进程的运行时间的记录非常重要。多数 Unix 系统,分配一个绝对时间的时间片给进程,当每次系统时钟节拍发生时,时间片都会被减少一个节拍周期。每当一个进程的时间片被减少到 0,他就会被尚未减少到 0 的进程抢占。

但 CFS 并没有绝对的时间片,但它依然需要对每个进程的运行时间记账,以确保每个进程只在公平分配给它的处理器运行时间内运行。而记账的信息会保存其结构体指针 se 在进程的 task_struct 中(参照前一篇文章《你需要了解的 Linux 进程管理》)。

在结构体中,有一个重要的成员变量 vruntime,即是记录了该进程的总运行时间(花在运行上的时间和),而且这个时间经过了加权(优先级、机器负载等因素)。虚拟时间是以 ns 为单位的,因此 vruntime 和系统定时器节拍不再相关。

内核通过定时调用 uodate_curr() 函数(定义在 kernel/sched_fair.c)来更新进程的 vruntime,该函数计算了当前进程的执行时间,并将调用 __uodate_curr() 获得根据机器负载(可运行的进程总数)对运行时间加权后的值,然后将该值与原有的 vruntime 相加获得新的 vruntime

选择投入运行的进程

如果存在上文中描述的 “完美多任务处理器”,那么每个可运行的进程的 vruntime 值应该一致。然而这样的处理器是不存在的,那么 CFS 就会尽力去向这个方向靠拢,即在每次选择进程时,会挑选 vruntime 最小的进程。

CFS 使用红黑树来组织可运行队列,红黑树简称 rbtree,是一种自平衡的二叉搜索树,树上的每一个节点都对应了一个键值,可以通过键值来快速检索节点上的数据。后面我会单独写文章介绍数据结构(数据库索引一般也是基于红黑树)。

CFS 会维护一个包含了所有可运行进程的红黑树(相关代码在 kernel/sched_fair.c 中),节点的键值便是可运行进程的虚拟运行时间,CFS 调度器需要选取下一个运行的进程时,只需要 __pick_next_entity() 方法找到树中虚拟运行时间最小的进程(即搜索树的最左侧的叶子节点,其实为了快速访问这个节点,内核专门拿出了一个全局变量),并将这个进程投入运行。

如果有阻塞的进程满足了等待的条件或者有通过 fork() 创建的新进程,便会通过 enqueue_entity() 方法插入到该红黑树中,为了保证能尽快响应,新插入的节点会和最左叶子节点比较,如果新节点是最左叶子节点,调度器会立马把新进程投入运行。

相反,如果有进程堵塞(或者其他)变为不可运行的状态,调度器会通过 dequeue_entity() 方法将该进程从红黑树中移除,如果移除的是最左节点,这会调用 rb_next() 方法找到新的最左节点。

调度器的选择

正如前面讲到的,内核支持多种调度器,而 CFS 只不过是其中一种。进程调度的主要入口点是定义在 kernel/sched.c 下的函数 schedule(),它完成的事情就是选择一个进程,并将其投入运行,而它的逻辑非常简单:

  1. 调用 pick_next_task() 获得一个 task
  2. 将这个 task 投入运行

而真正和调度器相关的逻辑在 pick_next_task() 中,pick_next_task() 做了下面的事情:

  1. 按照调度类的优先级遍历
  2. 只有该调度类下有可运行进程,立马返回

pick_next_task() 为 CFS 做了一个简单的优化,如果所有的可运行任务都在 CFS 调度器下,就不再遍历其他调度类,而是不停的从 CFS 调度器里拿任务。

睡眠与唤醒

休眠(阻塞)的进程处于一个特殊不可执行的状态,阻塞的原因可能很多,比如等待一个信号,或者等待用户键盘的输入等,无论哪种,内核的操作是相同的:进程把自己标记为休眠状态,从可执行红黑树中移除并放入等待队列,然后调用 schedule() 选择和执行一个其他进程。

上一篇文章讲过,休眠有两种进程状态:TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE,无论哪种状态,休眠的进程都在同一个等待队列上。

等待队列 是由等待某些事件发生的进程组成的简单链表,当与等待队列相关的事件发生时,队列上的进程会被唤醒,为了避免产生竞争条件,休眠和唤醒的实现不能有批量。但如果简单的实现,有可能导致在判定条件为真后,进程却开始了休眠,那么就会使进程无限期地休眠下去,因此进程按以下处理加入等待队列:

  1. 调用宏 DEFINE_WAIT() 创建一个等待队列的项
  2. 调用 add_wait_queue() 把自己加入到队列中
  3. 调用 prepare_to_wait() 将进程的状态变为 TASK_INTERRUPTIBLETASK_UNINTERRUPTIBLE
  4. 如果状态被设置的是 TASK_INTERRUPTIBLE 则信号唤醒(伪唤醒),以检查并处理信号
  5. 唤醒之后检查等待条件是否为真,是则跳出循环,否则再次调用 schedule() 并一直重复
  6. 跳出循环(条件满足)后,进程将自己设置为 TASK_RUNNING 并调用 finish_wait() 方法把自己移除等待队列

唤醒操作通过 wake_up() 完成,它会唤醒指定的等待队列上的所有进程,wake_up() 的主要逻辑在调用的 try_to_wake_up() 中:

  1. 将进程设置为 TASK_RUNNING
  2. 调用 enqueue_task() 将此进程放入红黑树中
  3. 如果唤醒的进程比当前执行的进程优先级高则立马抢占
  4. 设置 need_resched 标记(标记是否触发重新调度)

不过如上面提到的,因为有伪唤醒,所以进程被唤醒不一定都是因为等待的条件达成。

抢占进程上下文切换

上下文切换是指从一个可执行进程切换到另一个可执行进程。在文章的最后,讲讲进程的上下文切换和抢占的问题。

内核处理的上下文切换的函数 context_switch() 定义在 kernel/sched.c,他基本完成了两件事情:

  1. 调用 switch_mm() 把虚拟内存从上一个进程映射切换到新进程中
  2. 调用 switch_to() 从上一个进程处理器状态切换到新进程的处理器状态(包含栈信息、寄存器信息等)

内核即将返回用户空间的时候,如果 need_resched 被标记,则会导致 schedule() 被调用,此时就会发生用户抢占。因为从内核返回到用户空间的进程知道自己是安全的,它既可以继续执行,也可以选择一个新进程去执行,所以无论是系统调用后还是中断后,进程都可以检查 need_resched 被标记,来判断是否需要重新调用 schedule()。总之,一般用户抢占发生在:

  • 从系统调用返回用户空间
  • 从中断处理程序返回用户空间

因为 Linux 完整的支持内核抢占,所以只要调度是安全的(没有持有锁),内核就可以在任何时间抢占正在执行的任务。Linux 的锁的判断是通过计数实现的,在 thread_info 中引入了 preempt_count 来记录持有的锁,当该值为 0 的时候,则该进程是安全的,而恰好该新进程设置了 need_resched 标记,那么当前进程可以被抢占。

如果内核中的进程被阻塞了,或显式调用了 schedule(),则内核会发生显式的抢占。显式的抢占从来都是受支持的,因为如果一个函数显式的调用 schedule(),说明它自己是清楚可以被安全的抢占。

内核的抢占一般发生在:

  • 中断处理程序正在执行,且返回内核空间之前
  • 内核代码再一次具有可抢占性的时候
  • 如果内核中的任务显式调用 schedule() 的时候
  • 内核中的任务阻塞时
2018/6/3 posted in  Kernel

使用 Flask-RESTPlus 构建生产级应用

本文来自对某项目的实践总结,敏感信息已被隐藏或被 Resource 一词代替。

前几天有人辗转找到公众号,留言询问之前一篇介绍 Flask-RESTPlus 文章的源代码(Flask Api 文档管理与 Swagger 上手),Flask-RESTPlus 虽然看起来非常方便,但在实际编写代码时总有种和当前项目结构冲突的感觉,因此整理之前的一篇改造某项目的总结,分享并探讨最佳实践。

在生成 Swagger 文档上,Flask-RESTPlus 是比较常用的 flask 拓展,但引入该插件需要对项目结构些许调整,如果是从 0 到 1 的新项目,倒也无伤大雅,但是对于已经存在的旧项目,改造还是有一定的工作量的,本文通过总结具体的项目改造,对 Flask-RESTPlus 进一步的讲解,以此总结。

蓝图与 API

在大型 Flask 项目中,为了防止各个模块的依赖混乱,一般通过模块划分,并在 app 工厂方法中统一对各个模块的蓝图进行注册,Flask-RESTPlus 作为 flask 拓展可以通过与 flask app 绑定从而托管注册在 Flask-RESTPlus 的视图,比如官方文档的例子:

app = Flask(__name__)
api = Api(app)

但是这样会架空 flask 自带的蓝图,如果是新项目的话可以考虑使用 Flask-RESTPlus 的 Namespace 替代,但是如果是老项目迁移,成本还是蛮高的,因此可以将 蓝图与 Flask-RESTPlus Api 绑定,这样既保留了原有的模块划分,还可以利用 Namespace 进行更细致的逻辑划分。

比如对于当然项目来说,其中有多个 blueprint,来分割相对独立的模块,我们拿 Resource 模块举例,通过 flask 的蓝图对大模块进行划分之后,再通过 Namespace 对细节再次划分:

desc = """
resource type 1, type 2, type 3, type 4, type 5 api
"""

resource_blueprint = Blueprint("Resource", __name__)
api = Api(resource_blueprint, version='1.0', title='Resource info',
          description=desc)
api.add_namespace(resource_type_1_api)
api.add_namespace(resource_type_2_api)
api.add_namespace(resource_type_3_api)
api.add_namespace(resource_type_4_api)
api.add_namespace(resource_type_5_api)

Resource 支持五种不同的类型,虽然这几种类型的 api 同属在一个蓝图里,但是其本身相对独立,因此可以使用 Namespace 做更细致的区分,然后将这五个 namespace 注册到 api 里。因此 blueprints 目录结构如下:

.
├── __init__.py
├── action
│   ├── __init__.py
│   ├── apis.py
│   └── dto.py
├── health.py
├── json_schema.py
└── resource
    ├── __init__.py
    ├── type_1.py
    ├── type_2.py
    ├── type_3.py
    ├── type_4.py
    ├── type_5.py
    └── dto.py
    

参数检查与权限验证

解决了注册问题,还有部分公共设施需要修改,比如参数检查和 api 权限认证。在之前是这样处理的:

@resource_blueprint.route("/", methods=['POST'])
@internal_token_validator
@request_json_validator(SEND_TYPE_1_SCHEMA)
@tracing_span("post_type_1:type_1_api")
def op_type_1():
    pass

token 验证的逻辑写在 internal_token_validator 装饰器中,虽然 Flask-RESTPlus api 类支持注册装饰器,但是因为并不是所有的 api 都需要 token 认证,因此并不能直接注册在其中,但是有认证的 api 比例非常多,依然选择装饰器,那么装饰数量将要突破 6 个而且到处写一样的逻辑非常丑,因此我继承了 Flask-RESTPlus 视图类 Resource,并复写了 dispatch 函数,如果有方法需要 token 认证则动态将 internal_token_validator 装饰器放在 method_decorators 中,而后者会在 Flask-RESTPlus 处理视图方法时调用。

虽然 Flask-RESTPlus 提供了提供了参数验证的功能,但是对我们来讲并不够用(并不强大),而 DCS 中的参数验证一直使用的是 json-schema,在上面的例子中 request_json_validator 装饰器便是处理相关逻辑,该装饰器会将一个 json-schema 规则传入,然后在处理该 api 函数前将 request 中的 json body 验证,如果验证失败便会封装一个友好的 400 Response。

为了方便使用 json-schema 验证,我也将相关逻辑封装了继承的视图基类里,相关代码:

class BaseView(Resource):
    json_schemas = {}
    internal_token_required = ['get', 'post', 'delete', 'put', 'patch']

    def dispatch_request(self, *args, **kwargs):
        from message.common.errors import APIError
        try:
            method = request.method.lower()
            self.validate(self.json_schemas.get(method))
            if method in self.internal_token_required:
                self.method_decorators.append(internal_token_validator)
            return super(BaseView, self).dispatch_request(*args, **kwargs)
        except APIError as e:
            rsp = Response(
                response=json.dumps(e.render()), status=e.status_code,
                mimetype='application/json')
            return rsp

    @staticmethod
    def validate(schema):
        from message.common.errors import APIError
        if not schema:
            return
        data = request.json
        try:
            validate(data, schema)
        except ValidationError as e:
            raise APIError("ARGS_ERROR", e.message, 400)

DTO

最后谈一下导包的问题,在前一篇文章也提到 Flask-RESTPlus 容易产生相互引用, 而工程和 demo 不同,不能通过什么魔法技巧来避免这个问题 ,而应该通过更加细致的模块划分来避免,最后看到文章《How to structure a Flask-RESTPlus web service for production builds》(文后附链接)中介绍了 DTO 才让我找到了更 “结构化” 的解决办法。

DTO 即 data transfer object,这样设计的思路是和蓝图类似,传统 flask 应用中,在 app 工厂方法注册蓝图,而蓝图内的包相对独立,而 Flask-RESTPlus 引入了 namespace,按上文,我们把它作为蓝图更细以级的存在,因此,可以参考蓝图,将 namespace 的定义和依赖封装在一个类中,这样既避免了循环引用,还可以让整个项目的结构更清晰。

比如 Type1 DTO:

class Type1Dto:
    api = Namespace("type1", path="/type1", decorators=[internal_token_validator])
    action_model = api.model('ActionModel', action_desc)
    create_model = api.model("CreateType1Model", {
        "type1_title": fields.List(fields.String(description="Type1 title"), required=True),
        "type1_info": fields.Nested(api.model("Type1InfoModel", {
            "content": fields.String(description="Type1 content"),
        }), required=True),
    })
    template_model = api.model("TemplateType1Model", {
        "type1_title": fields.List(fields.String(description="Type1 title", required=True)),
        "content": fields.Nested(api.model("TemplateContent", {}), required=True),
    })
    model = api.model("Type1Model", {
        "total": fields.Integer(readOnly=True, description="action total"),
        "results": fields.List(fields.Nested(action_model)),
    })

其中包含了 namespace 的定义,request 的格式对象(Flask-RESTPlus 基于它生成 Request 文档),和 response 的返回对象(Flask-RESTPlus 基于它渲染 json 并生成 Response 文档)。

在使用时,将 dto 导入到视图层,而相关 model 也会在这派上用场:

from .dto import Type1Dto

api = Type1Dto.api


@api.route("/")
class Type1Api(Resource):
    json_schemas = {"post": SEND_TYPE_1_SCHEMA}

    @tracing_span("post_type_1:type_1_api")
    @api.expect(Type1Dto.create_model)
    @api.param("Token", description="internal token.", _in="header", required=True)
    def post(self):
        actions = deal_with_type_1(api.payload['type_1_title'], api.payload['content'])
        result = {
            "total": len(actions),
            "results": [a.render() for a in actions]
        }
        return result

最后将视图层的 api 导入到蓝图定义的地方完成注册,这样整个项目既做到了合理的结构分类,也完成和解决了导包问题。


参考资料:《How to structure a Flask-RESTPlus web service for production builds》: https://medium.freecodecamp.org/structuring-a-flask-restplus-web-service-for-production-builds-c2ec676de563

2018/5/26 posted in  Python 黑魔法

Linux 进程管理

最近开始对 Linux 进行一次比较深入的学习,对于一个操作系统来说,提供运行程序的能力是其本质,而在 Linux 中,轻量、相应快速的进程管理也是其优良特性之一。我会分两篇文章介绍 Linux 进程。这是第一篇,重点在于 Linux 进程的描述、和生命周期,下一篇将介绍 Linux 下的进程调度。

描述符与任务结构

进程就是处于执行期的程序,而通常还包括部分资源,比如:文件、挂起的信号、内核内部数据、处理器状态、内存空间以及一个或多个执行线程(thread of execution)。因此,可以认为一个进程就是一段可执行程序和计算机资源的集合。

进程的描述

在 Linux 内核中,会有一个被称作任务队列(Task list)的双向循环链表,被用来保存进程的描述(后面会讲到 Linux 线程和进程不做区别,任务队列也会用来保存线程描述)。链表中的每一个元素为 task_struct 类型的结构体(定义在 linux/sched.h 中),task_struct 相对较大,在 32 位系统的机器上,大约有 1.7KB 的大小,该结构体中包含的数据能完整的描述一个正在执行的程序:打开的文件、地址空间、信号、状态等。

Linux 通过 slab 分配器 分配 task_struct 结构。在 2.6 之前,各个进程的 task_struct 存放在他们内核栈的尾端,这样可以使得 x86 等寄存器比较少的硬件体系结构只要通过栈指针就能计算出它的位置,而对于现在比较新的硬件体系上,会专门拿出一个寄存器指向 task_struct,而只在进程内核栈的尾端保留一个 thread_info(定义在 asm/thread_info.h),其记录了执行线程的基础信息,并在 task 域中保留一个指向 task_struct 的指针。

PID

内核通过一个唯一的进程标识值(process identification value,PID)来表示每个进程,PID 是一个宏定义类型 pid_t,实际上就是一个 short int(为了保证兼容,采用 short 类型,最大值仅为 32768),对于 PID 最大有多大其实是受 linux/threads.h 中定义的限制,可以通过修改 /proc/sys/kernel/pid_max 来把 PID 增加至高达 400 万。但考虑到进程描述是存储在一个双向循环链表中的,进程越少意味着转一圈越快,进程数量巨大并不意味是一件好事。

进程的状态

task_struct 中,state 专门用来描述进程的当前状态,系统中的每个进程必然处于下面五种状态之一:

  • TASK_RUNNING(运行)
  • TASK_INTERRUPTIBLE(可中断)
  • TASK_UNINTERRUPTIBLE(不可中断)
  • __TASK_TRACED(被其他进程跟踪)
  • __TASK_STOPPED(停止)

TASK_RUNNING 状态下意味着进程是可以被执行的,或许进程正在执行,也可能在运行队列中等待被执行(后面的进程调度会讲到),只有该状态下,进程才有被执行的可能。

TASK_INTERRUPTIBLE 状态意味着进程正在休眠,也许因为被阻塞,或者等待某些条件的达成,一旦这些条件达成,内核就会把进程状态设置为运行,处于此状态的进程也会因为接收到信号被唤醒并随时准备投入运行。

TASK_UNINTERRUPTIBLE 和 TASK_INTERRUPTIBLE 类似,但是不同点在于即使进程收到信号也不会被唤醒或者准备投入运行。该状态下的进程可以处于一个不受打扰的状态,以等待完成某些条件,比如进程创建初期,在没有初始化完成时便是处于该状态。

__TASK_TRACED 意味着被其他进程跟踪,比如通过调试程序进程跟踪等。

__TASK_STOPPED 意味着进程已经停止了执行,比如在收到 SIGSTOP,SIGTSTP,SIGTTIN,SIGTTOU 等信号,此外,在调试期收到任何信号都会进入该状态。更多进程的停止时发生的事情后面会讲到。

上下文与家族树

一般程序在用户空间执行,但当一个程序执行了系统调用或者触发了某个异常,它就会陷入内核空间,此时被称作内核 “代表进程执行” 并处于进程上下文中,一般情况下,在内核退出时,程序恢复到用户空间继续执行,系统调用和异常处理程序是对内核明确定义的接口,进程只有通过这些接口才能陷入内核执行,即对内核的所有访问都必须通过这些接口。在后面会单独拿一篇文章介绍系统调用。

Unix 系统的进程之间存在一个明显的继承关系,在 Linux 系统中也是如此,所有的进程都是 PID 为 1 的 init 进程的后代。而 init 进程是在系统启动的最后阶段启动的第一个进程。该进程负责读取系统的初始化脚本并执行其他的相关程序,以完成系统启动的最后过程。

除了 init 进程,系统的每个进程必有一个父进程,而每个进程也可以拥有零个到多个子进程,进程间呈现的树状关系,使得拥有相同父进程的两个子进程被称为 “兄弟”。进程的关系存放在进程描述符中,每个 task_struct 都包含一个指向其父进程 task_struct 的指针(被称为 parent 指针)。还包含一个称为 children 的子进程链表。也正是拥有这样的数据结构,可以从任意进程的 task_struct 遍历系统中存在的整个家族树。

进程的创建

Unix 系统的进程创建很特别,其他系统都提供了 Spawn 进程机制,首先在新的地址空间里创建进程,读入可执行文件,最好开始执行。这是一种符合直觉的进程创建方式,但是 Unix 采用了与众不同的方式,它把上述过程分成了两步(分成两个独立的函数),fork 和 exec。

首先 fork() 通过拷贝当前进程创建一个子进程,而子进程与父进程的区别仅在于 PID(进程 ID)、PPID(父进程的 PID)、部分资源和统计量(被挂起的信号等没必要继承的资源)。除此之外,新创建的子进程将直接使用父进程的资源(直接将相关指针指向父进程的资源)。而 exec() 函数负责读取可执行文件并将其载入地址空间开始运行。将这两个函数连起来使用可以达到其他系统单独使用 spawn 函数的作用。

Linux 的 fork() 使用写时拷贝(copy-on-wirte)页实现,这就意味着在创建子进程时可以推迟甚至免除拷贝父进程的数据。在 fork 时内核并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝,只有到了需要写入的时候,数据才会被复制,从而使得各个进程拥有各自的拷贝。换句话说,资源的复制只会在需要写入的时候进行,在此之前,都是以只读的方式共享。因此 fork 的实际开销只有复制父进程的页表以及给子进程创建唯一的进程描述符。而且 Linux 做了一个巧妙的优化,在进程创建后会马上运行一个可执行文件,即先执行子进程,这样就避免了父进程对内存空间的修改导致触发写时拷贝。

Linux 通过 clone() 系统调用来实现 fork,而 fork() vfork() __clone() 函数都通过不同的参数来调用 clone() 系统调用,而 clone() 通过 do_frok()(定义在 kernel/fork.c)来完成 fork 绝大部分工作。

do_frok() 首选调用 copy_process 函数来拷贝进程,然后让进程开始运行,而 copy_process 函数即是 Linux 复制进程的具体实现:

  1. copy_process 首先调用 dup_task_struck() 为子进程创建内核栈、thread_info 结构和 task_struct,目前这些结构和内容与父进程完全一致。
  2. 检查并确保创建子进程后,用户所拥有的进程数目没有超过分配的资源限额。
  3. 着手使子进程与父进程区别开来,进程描述符内的不能继承的成员被清零或者初始化。
  4. 将进程状态设置为 TASK_UNINTERRUPTIBLE 避免被投入运行。
  5. 调用 copy_flags() 更新 task_struct 的标记变量,比如标明进程是否拥有超级用户权限的 PF_SUPERPRIV 被清零,标明进程没有调用 exec() 函数的 PF_FORKNOEXEC 被设置。
  6. 调用 alloc_pid() 获得一个有效的 PID
  7. 根据 clone() 系统调用获得的参数来复制或者共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等。
  8. 最后 copy_process 做些收尾工作并返回一个指向子进程的指针。

这时运行权又回到了 do_frok() 上,do_frok() 会检查 copy_process 返回的指针,并让子进程投入运行,正如上面所说,会优先让子进程投入运行,然后是父进程。至此,一个子进程的创建便完成了。

线程

线程机制是现代编程技术中常用的一种抽象概念,该机制提供了在同一程序内共享内存地址空间运行的同一组线程。这些线程还可以共享如打开的文件等其他资源,但是如上文提到的,从 Linux 内核的角度来看,并没有线程这个概念,这和 Linux 线程的实现有关。

Linux 没为线程准备特别的调度算法或定义特别的数据结构,而是被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的 task_struct,从内核的角度看,它就是一个普通的进程(只是线程和其他的一些进程共享某些资源而已)。

因此,Linux 的线程实现和 Windows 等其他操作系统的实现差距非常大,后者都在内核提供了专门的支持线程的机制(这些系统常把线程称为轻量级进程 lightweight processes)。”轻量级进程“ 这个说法本身就表现了 Linux 与其他系统的差异。其他系统中,相较于重量级进程,线程被抽象成一种耗费较少资源,运行迅速的执行单元,而在 Linux 中,进程本身就足够轻量,而线程只是进程间共享资源的手段而已。

因此,线程的创建也是通过 clone() 系统调用来实现,只不过在调用 clone() 时传递一些参数来标记需要共享的资源。

除了用户空间的线程外,内核经常需要在后台执行一些操作,这些任务一般是通过内核线程(kernel thread)完成。内核线程是独立运行在内核空间的标准进程,内核线程与普通进程的区别在于内核线程没有独立的地址空间(指向地址空间的 mm 指针为 NULL),它们只在内核空间运行,从不切换到用户空间去,而且内核进程和普通进程一样,可以被调度,可以被抢占。

在 Linux 可以通过 ps -ef 查看内核线程,内核线程只能通过内核线程创建,内核是通过从 kthreadd 内核进程中衍生出所有新的内核线程,其接口声明在 linux/kthread.h 中。内核线程启动后就一直运行直到调用 do_exit() 退出,或者由内核其他部分调用 kthread_stop() 退出,线程或者进程的终结,将在下面介绍。

进程的终结

当一个进程(线程)终结时,内核必须释放它所占有的资源并告知其父进程,而这两步便完成了资源释放和删除进程描述符。

释放资源

一般来说,进程的结束是由自身引起的,比如执行 exit()。但无论是主动或者被动,进程结束时都要靠 do_exit() 函数(定义在 kernel/exit.c)来 “处理身后事”:

  1. task_struct 中的标记成员设置为 PF_EXITING
  2. 调用 del_timer_sync() 删除内核定时器,以确保没有定时器在排队,也没有定时器处理程序在运行。
  3. 如果 BSD 的进程记账程序功能是开启的,则输出记账信息。
  4. 调用 exit_mm() 来释放进程占用的 mm_struct,如果没有别的进程共享它,便彻底释放。
  5. 调用 sem_exit(),如果进程排队等候 IPC 信号,则使它离开队列。
  6. 调用 exit_file()exit_fs(),以分别递减文件描述符、文件系统数据的引用计数,如果引用计数减低到零,则彻底释放。
  7. 把存放在 task_struct 的 exit_code 成员设置为 exit() 函数提供的退出码。
  8. 调用 exit_notify() 向父进程发送信号,并给退出进程的子进程寻找养父(养父为线程组中的其他任意进程或者 init 进程),并把退出进程的进程状态设置为 EXIT_ZOMBIE,即僵死状态。
  9. 调用 schedule() 释放执行权,因为此进程已经设置为僵死状态,所以该进程再也不会被执行, do_exit() 永不会返回。

至此,进程的所有资源就已经全部被释放了,但是为了方便父进程查询退出进程的状态(退出码等),进程的 task_struct 会被保留,直到父进程执行 wait() 函数。

删除进程描述符

wait() 这一族函数都是通过唯一的系统调用 wait4() 实现的,它实现了挂起自己的进程,直到其中一个子进程退出,此时函数会返回该子进程的 PID。此外,调用该函数时提供的指针会包含子函数退出时的退出码。

当最终需要释放进程描述符时,release_task() 会被调用并执行下面的任务:

  1. 调用 __exit_signal(),该函数调用 _unhash_process(),而后者又调用 detach_pid(),从 pidhash 上删除该进程,同时也会从任务列表中删除该进程。
  2. _exit_signal() 释放目前僵死进程所使用的所有剩余资源,并进行最终统计和记录。
  3. 如果这个进程是线程组的最后一个进程,并且领头进程已经死掉,那么继续通知僵死的领头进程的父进程。
  4. 调用 put_task_struct() 释放进程内核栈和 thread_info 结构所占的页,并释放 task_struct 所占的 slab 高速缓存。

至此,进程描述符和进程独享资源就全部释放掉了。

孤儿进程

如果父进程在子进程退出前退出,必须有机制保证子进程能找到一个新的父亲,否则这些成为孤儿的进程会在退出时永远处于僵死状态。系统会遍历进程所在线程组的其他进程寻找养父,如果线程组没有其他进程,他就会被挂在 init 进程上。

一旦系统为进程成功找到并设置了新的父进程,就不会再有出现驻留僵死进程的风险了,而 init 进程也会例行调用 wait() 来检查其子进程,清除所有与其相关的僵死进程。

总结

进程是一个非常基础和关键的抽象概念,位于每种现代操作系统的核心位置,本文通过介绍进程及其生命周期来描述其基本概念,下一篇会通过介绍进程的调度来描述进程运行时的样子。

2018/5/19 posted in  Kernel

Python 魔术方法

魔术方法是 Python 类中那些定义名称类似 __XXX__ 的函数,使用 Python 的魔术方法的优势在于可以使用 Python 提供的内置方法,比如 len()str(),或者重载运算符,让自定义对象类型可以表现得像内置类型一样。

Python 语言参考手册中一共列出了 83 个特殊方法的名字,其中 47 个用于实现算术运算、位运算和比较操作,概览如下。

跟运算符无关的特殊方法:

跟运算符相关的特殊方法:

有一份文档详细的介绍了所有的魔术方法的使用方式,本文只整理了常用魔术方法以及常见的坑。 文档见: http://pycoders-weekly-chinese.readthedocs.io/en/latest/issue6/a-guide-to-pythons-magic-methods.html

构造与析构

Python 对象通过 __init__ 进行初始化,但是在执行 __init__ 之前,首先被执行的是 __new__ 函数。在初始化一个对象时,会先调用 __new__ 方法,该方法是一个类方法,执行完相关逻辑之后调用 __init__ 并将初始化参数传递给后者。

__del__ 函数是 Python 对象的析构函数,无论是垃圾回收还是手动删除,都是执行这个方法:

class Model(object):
    def __init__(self):
        print("Run: __init__")

    def __del__(self):
        print("Run: __del__")


if __name__ == "__main__":
    m = Model()
    del m

结果:

Run: __init__
Run: __del__

打印

在 Python 魔术方法中,有两个和打印有关,__repr____str__

如果在 Python 直接打印一个类,将会获得一个内存地址:

<__main__.Model object at 0x10408fda0>

如果在解释器中想获得一个更友好的显示,__repr__ 是一个非常好的选择。另外,在不把 object 当做字符串处理的场合,也是会使用该方法。

而在把 object 当做字符串处理或者转换为字符串的场合,比如 print(object)"{}".format(object)str(object) 等,__str__ 将会被使用。

class Model(object):
    def __init__(self):
        self.id = uuid.uuid4()
        self.content = "fluent python demo"

    def __str__(self):
        return self.content

    def __repr__(self):
        return "<Model: {}>".format(self.id)

一般在 __str__ 中展现更人性化的信息,可能直接返回给用户;而 __repr__ 更多为了在调试中使用方便(解释器中等)。

另外,如果 __str__ 没有定义,那么将会直接使用 __repr__ 方法,因此如果不需要太多差别,只定义后者即可。

数组与字典

如果需要定义一个类似列表的自定义类,为了支持通过下标访问,可以覆写 __getitem__

class Model(object):
    def __init__(self):
        self.content = [r for r in range(0, 3)]

    def __getitem__(self, item):
        return self.content[item]

该类从此支持了下标访问,并且还支持切片,排序等 Python 内置方法:

if __name__ == "__main__":
    m = Model()
    print(m[1:], sorted(m, reverse=True))

结果:

[1, 2] [2, 1, 0]

对于赋值操作,只需要覆写 __setitem__ 方法:

def __setitem__(self, key, value):
    self.content[key] = value

同理,对于将自定义对象进行类似字典的操作,可以这样实现:

class Model(object):
    def __init__(self):
        self.content = {
            "key": "value"
        }

    def __getitem__(self, item):
        return self.content[item]

    def __setitem__(self, key, value):
        self.content[key] = value

类成员变量

类成员方法 __getattr____getattribute____setattr__ 是三个方便且危险的方法,可以用来定义类成员属性,前两个是用来获取,最后一个是用来赋值。

前两个的区别是 __getattribute__ 会被首先调用,只有 __getattribute__ 找不到的时候,才会调用 __getattr__

因为本身的类属性操作也是基于这几个方法,因此在覆写时应该避免循环调用最后栈溢出。

总结

简单讲了几种常见的魔术方法,魔术方法可以使得自定义类型支持 Python 原生、内置方法,因而使得代码风格更加简洁和一致。但是,也容易写出难以调试的魔法代码,简洁(或者说 geek)和可维护性可能本身就是两个极端,在使用魔术方法时还需要考虑后继维护者是否能理解自己的行为。

2018/5/16 posted in  流畅的 Python