文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

本文的一些概念依赖于上一篇 “CPU 拓扑:从 SMP 谈到 NUMA (理论篇)”,如果你对这块还没有概念,建议看完那篇再来看,如果对这块有自己独到的见解,欢迎探讨。

本文主要会侧重 NUMA 这块,我会通过自己的环境验证 NUMA 的几个概念,以便对 NUMA 的架构有个较深的印象。

NUMA 的几个概念:Node,Socket,Core,Thread


NUMA 技术的主要思想是将 CPU 进行分组,Node 即是分组的抽象,一个 Node 表示一个分组,一个分组可以由多个 CPU 组成。每个 Node 都有自己的本地资源,包括内存、IO 等。每个 Node 之间通过互联模块(QPI)进行通信,因此每个 Node 除了可以访问自己的本地内存之外,还可以访问远端 Node 的内存,只不过性能会差一些,一般用 distance 这个抽象的概念来表示各个 Node 之间互访资源的开销。

Node 是一个逻辑上的概念,与之相对的 Socket,是物理上的概念。它表示一颗物理 CPU 的封装,是主板上的 CPU 插槽,所以,一般就称之为插槽(敲黑板,这 Y 不是套接字吗?emmm……)

Core 就是 Socket 里独立的一组程序执行单元,称之为物理核。有了物理核,自然就有逻辑核,Thread 就是逻辑核。更专业的说法应该称之为超线程。

超线程是为了进一步提高 CPU 的处理能力,Intel 提出的新型技术。它能够将一个 Core 从逻辑上划分成多个逻辑核(一般是两个),每个逻辑核有独立的寄存器和中断逻辑,但是一个 Core 上的多个逻辑核共享 Core 内的执行单元和 Cache,频繁调度可能会引起资源竞争,影响性能。超线程必须要 CPU 支持才能开启。

综上所述,一个 NUMA Node 可以有一个或者多个 Socket,每个 Socket 也可以有一个(单核)或者多个(多核)Core,一个 Core 如果打开超线程,则会变成两个逻辑核(Logical Processor,简称 Processor)。

所以,几个概念从大到小排序依次是:

Node > Socket > Core > Processor

验证 CPU 拓扑


了解了以上基本概念,下面在实际环境中查看这些概念并验证。

Node

numactl --hardware 查看当前系统的 NUMA Node(numactl 是设定进程 NUMA 策略的命令行工具):

1
2
3
4
5
6
7
8
9
10
11
12
Linux # numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 12 13 14 15 16 17
node 0 size: 49043 MB
node 0 free: 20781 MB
node 1 cpus: 6 7 8 9 10 11 18 19 20 21 22 23
node 1 size: 49152 MB
node 1 free: 31014 MB
node distances:
node 0 1
0: 10 21
1: 21 10

可以得出的信息有:1)系统的 Node 数为 2;2)每个 Node 包含的 Processor 数为 12;3)每个 Node 的总内存大小和空闲内存大小;4)每个 Node 之间的 distance。

还可以查看 /sys/devices/system/node/ 目录,这里记录着具体哪些 Node。

Socket

/proc/cpuinfo 中记录着 Socket 信息,用 “physical id” 表示,可以用 cat /proc/cpuinfo | grep "physical id" 查看:

1
2
3
4
5
6
7
8
9
Linux # cat /proc/cpuinfo | grep "physical id"
physical id : 0
physical id : 0
physical id : 0
physical id : 0
physical id : 1
physical id : 1
physical id : 1
physical id : 1

可以看到有 2 个 Socket,我们还可以查看以下这几种变种:

1)查看有几个 Socket

1
2
3
4
Linux # grep 'physical id' /proc/cpuinfo | awk -F: '{print $2 | "sort -un"}'

0
1

1
2
3
Linux # grep 'physical id' /proc/cpuinfo | awk -F: '{print $2 | "sort -un"}' | wc -l

2

2)查看每个 Socket 有几个 Processor

1
2
3
4
Linux # grep 'physical id' /proc/cpuinfo | awk -F: '{print $2}' | sort | uniq -c

12 0
12 1

3)查看每个 Socket 有哪几个 Processor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Linux # awk -F: '{ 
> if ($1 ~ /processor/) {
> gsub(/ /,"",$2);
> p_id=$2;
> } else if ($1 ~ /physical id/){
> gsub(/ /,"",$2);
> s_id=$2;
> arr[s_id]=arr[s_id] " " p_id
> }
> }
>
> END{
> for (i in arr)
> print arr[i];
> }' /proc/cpuinfo | cut -c2-

0 1 2 3 4 5 12 13 14 15 16 17
6 7 8 9 10 11 18 19 20 21 22 23

Core

同样在 /proc/cpuinfo 中查看 Core 信息:

1
2
3
4
5
6
7
Linux # cat /proc/cpuinfo |grep "core id" | sort -u
core id : 0
core id : 1
core id : 2
core id : 3
core id : 4
core id : 5

上面的结果表明一个 Socket 有 5 个 Core。上面查到有 2 个 Socket,则一共就有 10 个 Core。

Processor

上面查看 Socket 信息时已经能够得到 Processor 的信息,总共有 24 个 Processor,不过也可以直接从 /proc/cpuinfo 中获取:

1)获取总的 Processor 数,查看 “processor” 字段:

1
2
3
Linux # cat /proc/cpuinfo | grep "processor" | wc -l

24

2)获取每个 Socket 的 Processor 数,查看 “siblings” 字段:

1
2
3
Linux # cat /proc/cpuinfo | grep "siblings" | sort -u

12

Cache

Cache 也一样通过 /proc/cpuinfo 查看:

1
2
3
4
5
processor  : 0

cache size : 15360 KB

cache_alignment : 64

不过这里的值 cache size 比较粗略,我们并不知道这个值是哪一级的 Cache 值(L1?L2?L3?),这种方法不能确定,我们换一种方法。

其实详细的 Cache 信息可以通过 sysfs 查看,如下:

比如查看 cpu0 的 cache 情况:

1
2
3
Linux # ls /sys/devices/system/cpu/cpu0/cache/

index0/ index1/ index2/ index3/

其中包含四个目录:
index0 存 L1 数据 Cache,index1 存 L1 指令 Cache,index2 存 L2 Cache,index3 存 L3 Cache。每个目录里面包含一堆描述 Cache 信息的文件。我们选 index0 具体看下:

其中,shared_cpu_list 和 shared_cpu_map 表示意思是一样的,都表示该 cache 被哪几个 processor 共享。对 shared_cpu_map 具体解释一下。

这个值表面上看是二进制,但其实是 16 进制,每个数字有 4 个bit,代表 4 个 cpu。比如上面的 001001 拆开后是:

1
0000 0000 0001 0000 0000 0001,1 bit 处即对应 cpu 标号,即 cpu0 和 cpu12。

同样我们可以对其他 index 进行统计,可以得出:
/proc/cpuinfo 中的 cache size 对应的 L3 Cache size

最后,综合以上所有信息我们可以绘制出一下的 CPU 拓扑图:

我们发现以上命令用得不太顺手,要获取多个数据需要输入多条命令,能不能一条命令就搞定,当然是有的,lscpu 就可以做到,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Linux # lscpu 
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Byte Order: Little Endian
CPU(s): 24 //共有24个逻辑CPU(threads)
On-line CPU(s) list: 0-23
Thread(s) per core: 2 //每个 Core 有 2 个 Threads
Core(s) per socket: 12 //每个 Socket 有 12 个 Threads
Socket(s): 2 //共有 2 个 Sockets
NUMA node(s): 2 //共有 2 个 Nodes
Vendor ID: GenuineIntel
CPU family: 6
Model: 63
Stepping: 2
CPU MHz: 2401.000
BogoMIPS: 4803.16
Virtualization: VT-x
L1d cache: 32K //L1 data cache 32k
L1 cache: 32K //L1 instruction cache 32k
L2 cache: 256K //L2 instruction cache 256k
L3 cache: 15360K //L3 instruction cache 15M
NUMA node0 CPU(s): 0-5,12-17
NUMA node1 CPU(s): 6-11,18-23

当然了,没有完美的命令,lscpu 也只能显示一些宽泛的信息,只是相对比较全面而已,更详细的信息,比如 Core 和 Cache 信息就得借助 cpuinfo 和 sysfs 了。

下面给大家提供一个脚本,能够比较直观的显示以上所有信息,有 shell 版的和 python 版的(不是我写的,文末附上了引用出处)。

大家有需要可以回复 “CPU” 获取,我就不贴出来了,显示的结果大概就是长下面这个样子:

python 版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
============================================================
Core and Socket Information (as reported by '/proc/cpuinfo')
============================================================

cores = [0, 1, 2, 3, 4, 5]

sockets = [0, 1]


Socket 0 Socket 1

-------- --------

Core 0 [0, 12] [6, 18]

Core 1 [1, 13] [7, 19]

Core 2 [2, 14] [8, 20]

Core 3 [3, 15] [9, 21]

Core 4 [4, 16] [10, 22]

Core 5 [5, 17] [11, 23]

Reference:

  1. 玩转 CPU 拓扑:
    http://blog.itpub.net/645199/viewspace-1421876/
  2. NUMA 体系结构详解
    https://blog.csdn.net/ustc_dylan/article/details/45667227(shell 代码引用)
  3. dpdk 源代码(python 代码引用)

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

随着计算机技术(特别是以芯片为主的硬件技术)的快速发展,CPU 架构逐步从以前的单核时代进阶到如今的多核时代,在多核时代里,多颗 CPU 核心构成了一个小型的“微观世界”。每颗 CPU 各司其职,并与其他 CPU 交互,共同支撑起了一个“物理世界”。从这个意义上来看,我们更愿意称这样的“微观世界”为 CPU 拓扑,就像一个物理网络一样,各个网络节点通过拓扑关系建立起连接来支撑起整个通信系统。

单核 or 多核 or 多 CPU or 超线程 ?


在单核时代,为了提升 CPU 的处理能力,普遍的做法是提高 CPU 的主频率,但一味地提高频率对于 CPU 的功耗也是影响很大的(CPU 的功耗正比于主频的三次方)。

另外一种做法是提高 IPC (每个时钟周期内执行的指令条数),这种做法要么是提高指令的并行度,要么是增加核数。显然,后一种方法更具有可扩展性,这也是摩尔定律的必然性。

CPU 的性能到底是如何体现的呢?为了弄清楚这个问题,我们结合单核 CPU 和多核 CPU 的结构来进一步剖析。

首先,对于一个单核结构,即一颗物理 CPU 封装里只集成了一个物理核心,其主要组件可以简化为:CPU 寄存器集合、中断逻辑、执行单元和 Cache,如下图:

对于一个多线程程序,主要通过时间片轮转的方式来获得 CPU 的执行权,从内部来看,这其实是串行执行的,性能自然并不怎么高。

其次,对于多核结构,则是在一颗物理 CPU 封装里集成了多个对等的物理核心,所谓对等,就是每个核心都有相同的内部结构。多个核心之间通过芯片内部总线来完成通信。随着 CPU 制造工艺的提升,每颗 CPU 封装中集成的物理核心也在不断提高。

对于一个多线程程序,这种情况能够实现真正的并发,但线程在不同核之间切换会存在一定的开销,但由于走的是芯片内部总线,开销相对会比较小。

除了上述两种结构,还有另外一种结构是多 CPU 结构,也就是多颗单独封装的 CPU 通过外部总线相连,构成的一个统一的计算平台。每个 CPU 都需要独立的电路支持,有自己的 Cache。它们之间的通信通过主板上的总线来完成。

同样对于一个多线程程序,不同于上一种情况的是,线程间的切换走的是外部总线,延迟较大,开销自然较大,而且对于有共享的数据还会因 Cache 一致性带来一定的开销(关于 Cache 下一小节说明)。

上述结构,一个 CPU 核心同一时间内只能执行一个线程,效率低下,为了提高 CPU 的利用率,CPU 芯片厂商又推出超线程(Hyper-Thread-ing)技术,即让一个物理核心同时执行多个线程,使整体性能得到提升。虽然物理上只有一个核心,但逻辑上被划分了多个逻辑核心,它们之间是完全隔离的。

对于每个逻辑核心,拥有完整独立的寄存器集合和中断逻辑,共享执行单元和 Cache。由于是共享执行单元,所以对高 IPC 的应用,其性能提升有限。

Cache


Cache 是一种 SRAM (Static Random Access Memory,静态访问随机存储器)。出于成本和生产工艺考虑,一般将 Cache 分为三级。一级(L1)访问速度最快,但是容量最小,一般只有几十 KB;二级(L2)次之,一般有几百 KB 到几 MB 不等,三级(LLC,Last Level Cache)最慢,但是容量也最大,一般有几 MB 到几十 MB。

一级 Cache 又分为数据 Cache 和指令 Cache,顾名思义,数据 Cache 用来存数据,指令 Cache 用来存指令。下图是一个简单的 Cache 系统逻辑示意图。

在多核结构中,每个物理核心都拥有独立的一级 Cache 和二级 Cache,而三级 Cache 是所有核心共享。这种共享需要解决的一个问题是公平地为每个核心分配 Cache 大小,避免 Cache 命中率低的问题。

对于现代计算机系统,说到 Cache,不得不提 TLB(Translation Look-aside Buffer) Cache。简单理解,如果说 Cache 存放的是内存中的内容,那么 TLB Cache 存放的是页表项。

为什么页表项需要用 Cache 存,原因当然是快。你可能觉得用三级 Cache 存就行了,为什么还要专门上 TLB Cache。

这里有两点考虑,一点是 TLB 采用基于内容的访问存储器 CAM,这种存储器能做到根据虚拟地址查询直接返回物理地址,效率极高,不需要像传统方式那样采用多级页表查询。另外一点是 Cache 的“淘汰”机制决定,Cache 会根据算法淘汰掉那些不常使用的内容,这对于页表这种需要频繁访问(每次程序寻址都要访问页表)的特性显然是矛盾的,所以就需要专门为页表提供一种特殊的 Cache,即 TLB Cache。

SMP or NUMA or MPP?


如果说前面咱们讨论的是 CPU 内部的“微观世界”,那么本节将跳出来,探讨一个系统级的“宏观世界”。

首先是 SMP,对称多处理器系统,指的是一种多个 CPU 处理器共享资源的电脑硬件架构,其中,每个 CPU 没有主从之分,地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)。

这种系统由于共享资源,不可避免地要加锁来解决资源竞争的问题,带来一定的性能开销,另外,扩展能力还非常有限,实验证明,SMP 系统最好的情况是有 2-4 个 CPU,适用于 PC、笔记本电脑和小型服务器等。

tips: 查看系统是否是 SMP 结构:

1
ls /sys/devices/system/node/     # 如果只看到一个 node0 那就是 SMP 架构

为了应对大规模的系统要求(特别是云计算环境),就研制出了 NUMA 结构,即非一致存储访问结构。

这种结构引入了 CPU 分组的概念,用 Node 来表示,一个 Node 可能包含多个物理 CPU 封装,从而包含多个 CPU 物理核心。每个 Node 有自己独立的资源,包括内存、IO 等。每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存,但很显然,访问远端 Node 的内存比访问本地内存要耗时很多,这也是 NUMA 架构的问题所在,我们在基于 NUMA 架构开发上层应用程序要尽可能避免跨 Node 内存访问。

NUMA 架构在 SMP 架构的基础上通过分组的方式增强了可扩展性,但从性能上看,随着 CPU 数量的增加,并不能线性增加系统性能,原因就在于跨 Node 内存访问的问题。所以,一般 NUMA 架构最多支持几百个 CPU 就不错了。

但对于很多大型计算密集型的系统来说,NUMA 显然有些吃力,所以,后来又出现了 MPP 架构,即海量并行处理架构。这种架构也有分组的概念,但和 NUMA 不同的是,它不存在异地内存访问的问题,每个分组内的 CPU 都有自己本地的内存、IO,并且不与其他 CPU 共享,是一种完全无共享的架构,因此它的扩展性最好,可以支持多达数千个 CPU 的量级。

总结


1、在芯片技术已然发展成熟的今天,性能低,上核不是问题,但上核就一定能提高性能吗,另外上核怎么很好地利用多核来完成自身进化,这些问题都值得深思。

2、NUMA 架构算是多核时代应用较大的一种 CPU 架构,本文从核心谈到系统,让大家有个全面的了解,下文会特别针对 NUMA 架构做一些实验验证。

Reference:
1.《深入浅出 DPDK》

  1. SMP、NUMA、MPP体系结构介绍:
    https://www.cnblogs.com/yubo/archive/2010/04/23/1718810.html

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

平常工作会涉及到一些 Linux 性能分析的问题,因此决定总结一下常用的一些性能分析手段,仅供参考。

说到性能分析,基本上就是 CPU、内存、磁盘 IO 以及网络这几个部分,本文先来看 CPU 这个部分。

1 CPU 基础信息


进行性能分析之前,首先得知道 CPU 有哪些信息,可以通过以下方法查看 CPU 配置信息。

lscpu

在 Linux 下,类似 lsxxx 这样的命令都是用来查看基本信息的,如 ls 查看当前目录文件信息,lscpu 就用来查看 CPU 信息,类似还有 lspci 查看 PCI 信息。

可以看到我的机器配置很低,1 核 2.5GHz(在阿里云买的最低配的服务器)。

/proc/cpuinfo

/proc 目录是内核透传出来给用户态使用的,里面记录着很多信息文件,比如还有内存文件 meminfo 等。可以使用 cat /proc/cpuinfo 查看 CPU 信息。

这里显示的信息可以具体到每个逻辑核上,由于我只有一个核,所以只显示一组信息。

dmidecode

这个命令是用来获取 DMI(Desktop Management Interface)硬件信息的,包括 BIOS、系统、主板、处理器、内存、缓存等等。对于 CPU 信息,可以使用 dmidecode -t processor 来查看。

2 CPU 使用情况分析


知道了 CPU 的基本信息,我们就可以使用另外的命令来对 CPU 的使用情况分析一通了。

top

相信大家对下面这玩意不陌生,Windows 的任务管理器,top 的作用和它是一样的。

top 显示的效果虽说不像它这么华丽,但已然让人惊呼他俩怎么长得这么像。

我们重点关注这么几个字段:

  • load average:三个数字分别表示最近 1 分钟,5 分钟和 15 分钟的负责,数值越大负载越重。一般要求不超过核数,比如对于单核情况要 < 1。如果机器长期处于高于核数的情况,说明机器 CPU 消耗严重了。
  • %Cpu(s):表示当前 CPU 的使用情况,如果要查看所有核(逻辑核)的使用情况,可以按下数字 “1” 查看。这里有几个参数,表示如下:
1
2
3
4
5
6
7
8
- us    用户空间占用 CPU 时间比例
- sy 系统占用 CPU 时间比例
- ni 用户空间改变过优先级的进程占用 CPU 时间比例
- id CPU 空闲时间比
- wa IO等待时间比(IO等待高时,可能是磁盘性能有问题了)
- hi 硬件中断
- si 软件中断
- st steal time

每个进程的使用情况:这里可以罗列每个进程的使用情况,包括内存和 CPU 的,如果要看某个具体的进程,可以使用 top -p pid 查看。

和 top 一样的还有一个改进版的工具:htop,功能和 top 一样的,只不过比 top 表现更炫酷,使用更方便,可以看下它的效果。

ps

可能很多人会忽略这个命令,觉得这不是查看进程状态信息的吗,其实非也,这个命令配合它的参数能显示很多功能。比如 ps aux。如果配合 watch,可以达到跟 top 一样的效果,如:watch -n 1 "ps aux"(-n 1 表示每隔 1s 更新一次)

vmstat

这个命令基本能看出当前机器的运行状态和问题,非常强大。可以使用 vmstat n 后面跟一个数字,表示每隔 ns 显示系统的状态,信息包括 CPU、内存和 IO 等。

几个关键的字段:

  • r 值:表示在 CPU 运行队列中等待的进程数,如果这个值很大,表示很多进程在排队等待执行,CPU 压力山大。
  • in 和 cs 值:表示中断次数和上下文切换次数,这两个值越大,表示系统在进行大量的进程(或线程)切换。切换的开销是非常大的,这时候应该减少系统进程(或线程)数。
  • us、sy、id、wa 值:这些值上面也提到过,分别表示用户空间进程,系统进程,空闲和 IO 等待的 CPU 占比,这里只有 id 很高是好的,表示系统比较闲,其他值飚高都不好。

这个工具强大之处在于它不仅可以分析 CPU,还可以分析内存、IO 等信息,犹如瑞士军刀。

dstat

这个命令也很强大,能显示 CPU 使用情况,磁盘 IO 情况,网络发包情况和换页情况,而且输出是彩色的,可读性比较强,相对于 vmstat 更加详细和直观。使用时可以直接输入命令,也可以带相关参数。

3 进程使用 CPU 情况分析


上面说的是系统级的分析,现在来看单个进程的 CPU 使用情况分析,以便于我们能对占用 CPU 过多的进程进行调试和分析,优化程序性能。

其实前面 top 和 ps 这样的命令就可以看每个进程的 CPU 使用情况,但我们需要更专业的命令。

pidstat

这个命令默认统计系统信息,也包括 CPU、内存和 IO 等,我们常用 pidstat -u -p pid [times] 来显示 CPU 统计信息。如下统计 pid = 802 的 CPU 信息。

strace

这个命令用来分析进程的系统调用情况,可以看进程都调用了哪些库和哪些系统调用,进而可以进一步优化程序。比如我们分析 ls 的系统调用情况,就可以用 strace ls:

可以看到,一个简单的 ls 命令,其实有不少系统调用的操作。

此外,还可以 attach(附着)到一个正在运行的进程上进行分析,比如我 attach 到 802 这个进程显示:

根据这些输出信息,其实就能够很好地帮我们分析问题,从而定位到问题所在了。

OK,以上就是平常比较常用的一些工具,当然除了这些,还有很多很多工具,下面放一张图,来自 Linux 大牛,Netflix 高级性能架构师 Brendan Gregg。看完了,你也许会感叹“这世界太疯狂了(just crazy)”。

Reference:
[1]. http://rdc.hundsun.com/portal/article/731.html

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

前面两篇文章我们总结了 Docker 背后使用的资源隔离技术 Linux namespace,本篇将讨论另外一个技术——资源限额,这是由 Linux cgroups 来实现的。

cgroups 是 Linux 内核提供的一种机制,这种机制可以根据需求把一系列任务及子任务整合(或分隔)到按资源划分等级的不同组内,从而为系统资源管理提供一个统一的框架。(来自 《Docker 容器与容器云》)

通俗来说,cgroups 可以限制和记录任务组(进程组或线程组)使用的物理资源(包括 CPU、内存、IO 等)。

为了方便用户(程序员)操作,cgroups 以一个伪文件系统的方式实现,并对外提供 API,用户对文件系统的操作就是对 cgroups 的操作。

从实现上来,cgroups 实际上是给每个执行任务挂了一个钩子,当任务执行过程中涉及到对资源的分配使用时,就会触发钩子上的函数对相应的资源进行检测,从而对资源进行限制和优先级分配。

cgroups 的作用


总结下来,cgroups 提供以下四个功能:

资源限制: cgroups 可以对任务使用的资源总额进行限制,如设定应用运行时使用内存的上限,一旦超过这个配额就发出 OOM(Out of Memory)提示。

优先级分配: 通过分配的 CPU 时间片数量和磁盘 IO 带宽大小,实际上就相当于控制了任务运行的优先级。

资源统计: cgroups 可以统计系统的资源使用,如 CPU 使用时长、内存用量等,这个功能非常适用于计费。

任务控制: cgroups 可以对任务执行挂起、恢复等操作。

cgroups 的子系统


cgroups 在设计时根据不同的资源类别分为不同的子系统,一个子系统本质上是一个资源控制器,比如 CPU 资源对应 CPU 子系统,负责控制 CPU 时间片的分配,内存对应内存子系统,负责限制内存的使用量。进一步,一个子系统或多个子系统可以组成一个 cgroup,cgroups 中的资源控制都是以 cgroup 为单位来实现,一个任务(或进程或线程)可以加入某个 cgroup,也可以从一个 cgroup 移动到另一个 cgroup,但这里有一些限制,在此就不再赘述了,详细查阅相关资料了解。

对于我们来说,最关键的是知道怎么用,下面就针对 CPU、内存和 IO 资源来看 Docker 是如何使用的?

对于 CPU,Docker 使用参数 -c 或 –cpu-shares 来设置一个容器使用的 CPU 权重,权重的大小也影响了 CPU 使用的优先级。

如下,启动两个容器,并分配不同的 CPU 权重,最终 CPU 使用率情况:

1
2
docker run --name "container_A" -c 1024 ubuntu
docker run --name "container_B" -c 512 ubuntu

当只有一个容器时,即使指定较少的 CPU 权重,它也会占满整个 CPU,说明这个权重只是相对权重,如下将上面的 “container_A” 停止,“container_B” 就分配到全部可用的 CPU。

对于内存,Docker 使用 -m(设置内存的限额)和 –memory-swap(设置内存和 swap 的限额)来控制容器内存的使用量,如下,给容器限制 200M 的内存和 100M 的 swap,然后给容器内的一个工作线程分配 280M 的内存,因为 280M 在容许的 300M 范围内,没有问题。其内存分配过程是不断分配又释放,如下:

如果让工作线程使用内存超过 300M,则出现内存超限的错误,容器退出,如下:

对于 IO 资源,其使用方式与 CPU 一样,使用 –blkio-weight 来设置其使用权重,IO 衡量的两个指标是 bps(byte per second,每秒读写的数据量) 和 iops(io per second, 每秒 IO 的次数),实际使用,一般使用这两个指标来衡量 IO 读写的带宽,几种使用参数如下:

  • –device-read-bps,限制读某个设备的 bps。
  • –device-write-bps,限制写某个设备的 bps。
  • –device-read-iops,限制读某个设备的 iops。
  • –device-write-iops,限制写某个设备的 iops。

假如限制容器对其文件系统 /dev/sda 的 bps 写速率为 30MB/s,则在容器中用 dd 测试其写磁盘的速率如下,可见小于 30MB/s。

如果是正常情况下,我的机器可以达到 56.7MB/s,一般都是超 1G 的。

上面几个资源使用限制的例子,本质上都是调用了 Linux kernel 的 cgroups 机制来实现的,每个容器创建后,Linux 会为每个容器创建一个 cgroup 目录,以容器的 ID 命名,目录在 /sys/fs/cgroup/ 中,针对上面的 CPU 资源限制的例子,我们可以在 /sys/fs/cgroup/cpu/docker 中看到相关信息,如下:

其中,cpu.shares 中保存的就是限制的数值,其他还有很多项,感兴趣可以动手实验看看。

总结


cgroups 的作用,cgroups 的实现,cgroups 的子系统机制,CPU、内存和 IO 的使用方式,以及对应 Linux 的 cgroups 文件目录。

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

上篇我们从进程 clone 的角度,结合代码简单分析了 Linux 提供的 6 种 namespace,本篇从源码上进一步分析 Linux namespace,让你对 Docker namespace 的隔离机制有更深的认识。我用的是 Linux-4.1.19 的版本,由于 namespace 模块更新都比较少,所以,只要 3.0 以上的版本都是差不多的。

从内核进程描述符 task_struct 开始切入


由于 Linux namespace 是用来做进程资源隔离的,所以在进程描述符中,一定有 namespace 所对应的信息,我们可以从这里开始切入代码。

首先找到描述进程信息 task_struct,找到指向 namespace 的结构 struct *nsproxy(sched.h):

1
2
3
4
5
6
struct task_struct {
......
/* namespaces */
struct nsproxy *nsproxy;
......
}

其中 nsproxy 结构体定义在 nsproxy.h 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
* A structure to contain pointers to all per-process
* namespaces - fs (mount), uts, network, sysvipc, etc.
*
* 'count' is the number of tasks holding a reference.
* The count for each namespace, then, will be the number
* of nsproxies pointing to it, not the number of tasks.
*
* The nsproxy is shared by tasks which share all namespaces.
* As soon as a single namespace is cloned or unshared, the
* nsproxy is copied.
*/
struct nsproxy {
atomic_t count;
struct uts_namespace *uts_ns;
struct ipc_namespace *ipc_ns;
struct mnt_namespace *mnt_ns;
struct pid_namespace *pid_ns;
struct net *net_ns;
};
extern struct nsproxy init_nsproxy;

这个结构是被所有 namespace 所共享的,只要一个 namespace 被 clone 了,nsproxy 也会被 clone。注意到,由于 user namespace 是和其他 namespace 耦合在一起的,所以没出现在上述结构中。

同时,nsproxy.h 中还定义了一些对 namespace 的操作,包括 copy_namespaces 等。

1
2
3
4
5
6
int copy_namespaces(unsigned long flags, struct task_struct *tsk);
void exit_task_namespaces(struct task_struct *tsk);
void switch_task_namespaces(struct task_struct *tsk, struct nsproxy *new);
void free_nsproxy(struct nsproxy *ns);
int unshare_nsproxy_namespaces(unsigned long, struct nsproxy **,
struct fs_struct *);

task_struct,nsproxy,几种 namespace 之间的关系如下所示:

各个 namespace 的初始化


在各个 namespace 结构定义下都有个 init 函数,nsproxy 也有个 init_nsproxy 函数,init_nsproxy 在 task 初始化的时候会被初始化,附带的,init_nsproxy 中定义了各个 namespace 的 init 函数,如下:
在 init_task 函数中(init_task.h):

1
2
3
4
5
6
7
8
9
10
/*
* INIT_TASK is used to set up the first task table, touch at
* your own risk!. Base=0, limit=0x1fffff (=2MB)
*/
#define INIT_TASK(tsk) \
{
......
.nsproxy = &init_nsproxy,
......
}

继续跟进 init_nsproxy,在 nsproxy.c 中:

1
2
3
4
5
6
7
8
9
10
11
12
struct nsproxy init_nsproxy = {
.count = ATOMIC_INIT(1),
.uts_ns = &init_uts_ns,
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
.ipc_ns = &init_ipc_ns,
#endif
.mnt_ns = NULL,
.pid_ns_for_children = &init_pid_ns,
#ifdef CONFIG_NET
.net_ns = &init_net,
#endif
};

可见,init_nsproxy 中,对 uts, ipc, pid, net 都进行了初始化,但 mount 却没有。

创建新的 namespace


初始化完之后,下面看看如何创建一个新的 namespace,通过前面的文章,我们知道是通过 clone 函数来完成的,在 Linux kernel 中,fork/vfork() 对 clone 进行了封装,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
return do_fork(SIGCHLD, 0, 0, NULL, NULL);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif

#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, 0, 0, NULL, NULL);
}
#endif

#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int, tls_val,
int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
int __user *, parent_tidptr,
int __user *, child_tidptr,
int, tls_val)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
int, stack_size,
int __user *, parent_tidptr,
int __user *, child_tidptr,
int, tls_val)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
int, tls_val)
#endif
{
return do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);
}
#endif

可以看到,无论是 fork() 还是 vfork(),最终都会调用到 do_fork() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
/*
* Ok, this is the main fork-routine.
*
* It copies the process, and if successful kick-starts
* it and waits for it to finish using the VM if required.
*/
long do_fork(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *parent_tidptr,
int __user *child_tidptr)
{
// 创建进程描述符指针
struct task_struct *p;
int trace = 0;
long nr;

/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;

if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}

// 复制进程描述符,返回值是 task_struct
p = copy_process(clone_flags, stack_start, stack_size,
child_tidptr, NULL, trace);
/*
* Do this prior waking up the new thread - the thread pointer
* might get invalid after that point, if the thread exits quickly.
*/
if (!IS_ERR(p)) {
struct completion vfork;
struct pid *pid;

trace_sched_process_fork(current, p);

// 得到新进程描述符的 pid
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);

if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);

// 调用 vfork() 方法,完成相关的初始化工作
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}

// 将新进程加入到调度器中,为其分配 CPU,准备执行
wake_up_new_task(p);

// fork() 完成,子进程开始运行,并让 ptrace 跟踪
/* forking complete and child started to run, tell ptracer */
if (unlikely(trace))
ptrace_event_pid(trace, pid);

// 如果是 vfork(),将父进程加入等待队列,等待子进程完成
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}

put_pid(pid);
} else {
nr = PTR_ERR(p);
}
return nr;
}

do_fork() 首先调用 copy_process 将父进程信息复制给子进程,然后调用 vfork() 完成相关的初始化工作,接着调用 wake_up_new_task() 将进程加入调度器中,为之分配 CPU。最后,等待子进程退出。

copy_process():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
static struct task_struct *copy_process(unsigned long clone_flags,
unsigned long stack_start,
unsigned long stack_size,
int __user *child_tidptr,
struct pid *pid,
int trace)
{
int retval;
// 创建进程描述符指针
struct task_struct *p;

// 检查 clone flags 的合法性,比如 CLONE_NEWNS 与 CLONE_FS 是互斥的
if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
return ERR_PTR(-EINVAL);

if ((clone_flags & (CLONE_NEWUSER|CLONE_FS)) == (CLONE_NEWUSER|CLONE_FS))
return ERR_PTR(-EINVAL);

/*
* Thread groups must share signals as well, and detached threads
* can only be started up within the thread group.
*/
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);

/*
* Shared signal handlers imply shared VM. By way of the above,
* thread groups also imply shared VM. Blocking this case allows
* for various simplifications in other code.
*/
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);

/*
* Siblings of global init remain as zombies on exit since they are
* not reaped by their parent (swapper). To solve this and to avoid
* multi-rooted process trees, prevent global and container-inits
* from creating siblings.
*/
// 比如CLONE_PARENT时得检查当前signal flags是否为SIGNAL_UNKILLABLE,防止kill init进程。
if ((clone_flags & CLONE_PARENT) &&
current->signal->flags & SIGNAL_UNKILLABLE)
return ERR_PTR(-EINVAL);

/*
* If the new process will be in a different pid or user namespace
* do not allow it to share a thread group or signal handlers or
* parent with the forking task.
*/
if (clone_flags & CLONE_SIGHAND) {
if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
(task_active_pid_ns(current) !=
current->nsproxy->pid_ns_for_children))
return ERR_PTR(-EINVAL);
}

retval = security_task_create(clone_flags);
if (retval)
goto fork_out;

retval = -ENOMEM;
// 复制当前的 task_struct
p = dup_task_struct(current);
if (!p)
goto fork_out;

ftrace_graph_init_task(p);

rt_mutex_init_task(p);

#ifdef CONFIG_PROVE_LOCKING
DEBUG_LOCKS_WARN_ON(!p->hardirqs_enabled);
DEBUG_LOCKS_WARN_ON(!p->softirqs_enabled);
#endif
retval = -EAGAIN;

// 检查进程是否超过限制,由 OS 定义
if (atomic_read(&p->real_cred->user->processes) >=
task_rlimit(p, RLIMIT_NPROC)) {
if (p->real_cred->user != INIT_USER &&
!capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
goto bad_fork_free;
}
current->flags &= ~PF_NPROC_EXCEEDED;

retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;

/*
* If multiple threads are within copy_process(), then this check
* triggers too late. This doesn't hurt, the check is only there
* to stop root fork bombs.
*/
retval = -EAGAIN;
// 检查进程数是否超过 max_threads,由内存大小定义
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;

// ......

// 初始化 io 计数器
task_io_accounting_init(&p->ioac);
acct_clear_integrals(p);

// 初始化 CPU 定时器
posix_cpu_timers_init(p);

// ......

// 初始化进程数据结构,并为进程分配 CPU,进程状态设置为 TASK_RUNNING
/* Perform scheduler related setup. Assign this task to a CPU. */
retval = sched_fork(clone_flags, p);

if (retval)
goto bad_fork_cleanup_policy;

retval = perf_event_init_task(p);
if (retval)
goto bad_fork_cleanup_policy;
retval = audit_alloc(p);
if (retval)
goto bad_fork_cleanup_perf;
/* copy all the process information */
// 复制所有进程信息,包括文件系统,信号处理函数、信号、内存管理等
shm_init_task(p);
retval = copy_semundo(clone_flags, p);
if (retval)
goto bad_fork_cleanup_audit;
retval = copy_files(clone_flags, p);
if (retval)
goto bad_fork_cleanup_semundo;
retval = copy_fs(clone_flags, p);
if (retval)
goto bad_fork_cleanup_files;
retval = copy_sighand(clone_flags, p);
if (retval)
goto bad_fork_cleanup_fs;
retval = copy_signal(clone_flags, p);
if (retval)
goto bad_fork_cleanup_sighand;
retval = copy_mm(clone_flags, p);
if (retval)
goto bad_fork_cleanup_signal;
// !!! 复制 namespace
retval = copy_namespaces(clone_flags, p);
if (retval)
goto bad_fork_cleanup_mm;
retval = copy_io(clone_flags, p);
if (retval)
goto bad_fork_cleanup_namespaces;
// 初始化子进程内核栈
retval = copy_thread(clone_flags, stack_start, stack_size, p);
if (retval)
goto bad_fork_cleanup_io;
// 为新进程分配新的 pid
if (pid != &init_struct_pid) {
pid = alloc_pid(p->nsproxy->pid_ns_for_children);
if (IS_ERR(pid)) {
retval = PTR_ERR(pid);
goto bad_fork_cleanup_io;
}
}

// ......

// 返回新进程 p
return p;
}

copy_process 主要分为三步:首先调用 dup_task_struct() 复制当前的进程描述符信息 task_struct,为新进程分配新的堆栈,第二步调用 sched_fork() 初始化进程数据结构,为其分配 CPU,把进程状态设置为 TASK_RUNNING,最后一步就是调用 copy_namespaces() 复制 namesapces。我们重点关注最后一步 copy_namespaces():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*
* called from clone. This now handles copy for nsproxy and all
* namespaces therein.
*/
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
{
struct nsproxy *old_ns = tsk->nsproxy;
struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
struct nsproxy *new_ns;

if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
CLONE_NEWPID | CLONE_NEWNET)))) {
get_nsproxy(old_ns);
return 0;
}

if (!ns_capable(user_ns, CAP_SYS_ADMIN))
return -EPERM;

/*
* CLONE_NEWIPC must detach from the undolist: after switching
* to a new ipc namespace, the semaphore arrays from the old
* namespace are unreachable. In clone parlance, CLONE_SYSVSEM
* means share undolist with parent, so we must forbid using
* it along with CLONE_NEWIPC.
*/
if ((flags & (CLONE_NEWIPC | CLONE_SYSVSEM)) ==
(CLONE_NEWIPC | CLONE_SYSVSEM))
return -EINVAL;

new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
if (IS_ERR(new_ns))
return PTR_ERR(new_ns);

tsk->nsproxy = new_ns;
return 0;
}

可见,copy_namespace() 主要基于“旧的” namespace 创建“新的” namespace,核心函数在于 create_new_namespaces:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/*
* Create new nsproxy and all of its the associated namespaces.
* Return the newly created nsproxy. Do not attach this to the task,
* leave it to the caller to do proper locking and attach it to task.
*/
static struct nsproxy *create_new_namespaces(unsigned long flags,
struct task_struct *tsk, struct user_namespace *user_ns,
struct fs_struct *new_fs)
{
struct nsproxy *new_nsp;
int err;

// 创建新的 nsproxy
new_nsp = create_nsproxy();
if (!new_nsp)
return ERR_PTR(-ENOMEM);

//创建 mnt namespace
new_nsp->mnt_ns = copy_mnt_ns(flags, tsk->nsproxy->mnt_ns, user_ns, new_fs);
if (IS_ERR(new_nsp->mnt_ns)) {
err = PTR_ERR(new_nsp->mnt_ns);
goto out_ns;
}
//创建 uts namespace
new_nsp->uts_ns = copy_utsname(flags, user_ns, tsk->nsproxy->uts_ns);
if (IS_ERR(new_nsp->uts_ns)) {
err = PTR_ERR(new_nsp->uts_ns);
goto out_uts;
}
//创建 ipc namespace
new_nsp->ipc_ns = copy_ipcs(flags, user_ns, tsk->nsproxy->ipc_ns);
if (IS_ERR(new_nsp->ipc_ns)) {
err = PTR_ERR(new_nsp->ipc_ns);
goto out_ipc;
}
//创建 pid namespace
new_nsp->pid_ns_for_children =
copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);
if (IS_ERR(new_nsp->pid_ns_for_children)) {
err = PTR_ERR(new_nsp->pid_ns_for_children);
goto out_pid;
}
//创建 network namespace
new_nsp->net_ns = copy_net_ns(flags, user_ns, tsk->nsproxy->net_ns);
if (IS_ERR(new_nsp->net_ns)) {
err = PTR_ERR(new_nsp->net_ns);
goto out_net;
}

return new_nsp;
// 出错处理
out_net:
if (new_nsp->pid_ns_for_children)
put_pid_ns(new_nsp->pid_ns_for_children);
out_pid:
if (new_nsp->ipc_ns)
put_ipc_ns(new_nsp->ipc_ns);
out_ipc:
if (new_nsp->uts_ns)
put_uts_ns(new_nsp->uts_ns);
out_uts:
if (new_nsp->mnt_ns)
put_mnt_ns(new_nsp->mnt_ns);
out_ns:
kmem_cache_free(nsproxy_cachep, new_nsp);
return ERR_PTR(err);
}

在create_new_namespaces()中,分别调用 create_nsproxy(), create_utsname(), create_ipcs(), create_pid_ns(), create_net_ns(), create_mnt_ns() 来创建 nsproxy 结构,uts,ipcs,pid,mnt,net。

具体的函数我们就不再分析,基本到此为止,我们从子进程创建,到子进程相关的信息的初始化,包括文件系统,CPU,内存管理等,再到各个 namespace 的创建,都走了一遍,下面附上 namespace 创建的代码流程图。

mnt namespace:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
mnt namespace:
struct mnt_namespace *copy_mnt_ns(unsigned long flags, struct mnt_namespace *ns,
struct user_namespace *user_ns, struct fs_struct *new_fs)
{
struct mnt_namespace *new_ns;
struct vfsmount *rootmnt = NULL, *pwdmnt = NULL;
struct mount *p, *q;
struct mount *old;
struct mount *new;
int copy_flags;

BUG_ON(!ns);

if (likely(!(flags & CLONE_NEWNS))) {
get_mnt_ns(ns);
return ns;
}

old = ns->root;
// 分配新的 mnt namespace
new_ns = alloc_mnt_ns(user_ns);
if (IS_ERR(new_ns))
return new_ns;

namespace_lock();
/* First pass: copy the tree topology */
// 首先 copy root 路径
copy_flags = CL_COPY_UNBINDABLE | CL_EXPIRE;
if (user_ns != ns->user_ns)
copy_flags |= CL_SHARED_TO_SLAVE | CL_UNPRIVILEGED;
new = copy_tree(old, old->mnt.mnt_root, copy_flags);
if (IS_ERR(new)) {
namespace_unlock();
free_mnt_ns(new_ns);
return ERR_CAST(new);
}
new_ns->root = new;
list_add_tail(&new_ns->list, &new->mnt_list);

/*
* Second pass: switch the tsk->fs->* elements and mark new vfsmounts
* as belonging to new namespace. We have already acquired a private
* fs_struct, so tsk->fs->lock is not needed.
*/
// 为新进程设置 fs 信息
p = old;
q = new;
while (p) {
q->mnt_ns = new_ns;
if (new_fs) {
if (&p->mnt == new_fs->root.mnt) {
new_fs->root.mnt = mntget(&q->mnt);
rootmnt = &p->mnt;
}
if (&p->mnt == new_fs->pwd.mnt) {
new_fs->pwd.mnt = mntget(&q->mnt);
pwdmnt = &p->mnt;
}
}
p = next_mnt(p, old);
q = next_mnt(q, new);
if (!q)
break;
while (p->mnt.mnt_root != q->mnt.mnt_root)
p = next_mnt(p, old);
}
namespace_unlock();

if (rootmnt)
mntput(rootmnt);
if (pwdmnt)
mntput(pwdmnt);

return new_ns;
}

可以看到,mount namespace 在新建时会新建一个新的 namespace,然后将父进程的 namespace 拷贝过来,并将 mount->mnt_ns 指向新的 namespace。接着设置进程的 root 路径以及当前路径到新的 namespace,然后为新进程设置新的 vfs 等。从这里就可以看出,在子进程中进行 mount 操作不会影响到父进程中的 mount 信息。

uts namespace:

1
2
3
4
5
6
7
8
static inline struct uts_namespace *copy_utsname(unsigned long flags,
struct user_namespace *user_ns, struct uts_namespace *old_ns)
{
if (flags & CLONE_NEWUTS)
return ERR_PTR(-EINVAL);

return old_ns;
}

uts namespace 直接返回父进程 namespace 信息。

ipc namespace:

1
2
3
4
5
6
7
struct ipc_namespace *copy_ipcs(unsigned long flags,
struct user_namespace *user_ns, struct ipc_namespace *ns)
{
if (!(flags & CLONE_NEWIPC))
return get_ipc_ns(ns);
return create_ipc_ns(user_ns, ns);
}

ipc namespace 如果是设置了参数 CLONE_NEWIPC,则直接返回父进程的 namespace,否则返回新创建的 namespace。

pid namespace:

1
2
3
4
5
6
7
static inline struct pid_namespace *copy_pid_ns(unsigned long flags,
struct user_namespace *user_ns, struct pid_namespace *ns)
{
if (flags & CLONE_NEWPID)
ns = ERR_PTR(-EINVAL);
return ns;
}

pid namespace 直接返回父进程的 namespace。

net namespace

1
2
3
4
5
6
7
static inline struct net *copy_net_ns(unsigned long flags,
struct user_namespace *user_ns, struct net *old_net)
{
if (flags & CLONE_NEWNET)
return ERR_PTR(-EINVAL);
return old_net;
}

net namespace 也是直接返回父进程的 namespace。

OK,不知不觉写了这么多,但回头去看,这更像是代码走读,分析深度不够,更详细的大家可以参照源码,源码结构还是比较清晰的。

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

Docker 是“新瓶装旧酒”的产物,依赖于 Linux 内核技术 chroot 、namespace 和 cgroup。本篇先来看 namespace 技术。

Docker 和虚拟机技术一样,从操作系统级上实现了资源的隔离,它本质上是宿主机上的进程(容器进程),所以资源隔离主要就是指进程资源的隔离。实现资源隔离的核心技术就是 Linux namespace。这技术和很多语言的命名空间的设计思想是一致的(如 C++ 的 namespace)。

隔离意味着可以抽象出多个轻量级的内核(容器进程),这些进程可以充分利用宿主机的资源,宿主机有的资源容器进程都可以享有,但彼此之间是隔离的,同样,不同容器进程之间使用资源也是隔离的,这样,彼此之间进行相同的操作,都不会互相干扰,安全性得到保障。

为了支持这些特性,Linux namespace 实现了 6 项资源隔离,基本上涵盖了一个小型操作系统的运行要素,包括主机名、用户权限、文件系统、网络、进程号、进程间通信。

这 6 项资源隔离分别对应 6 种系统调用,通过传入上表中的参数,调用 clone() 函数来完成。

1
int clone(int (*child_func)(void *), void *child_stack, int flags, void *arg);

clone() 函数相信大家都不陌生了,它是 fork() 函数更通用的实现方式,通过调用 clone(),并传入需要隔离资源对应的参数,就可以建立一个容器了(隔离什么我们自己控制)。

一个容器进程也可以再 clone() 出一个容器进程,这是容器的嵌套。

如果想要查看当前进程下有哪些 namespace 隔离,可以查看文件 /proc/[pid]/ns (注:该方法仅限于 3.8 版本以后的内核)。

可以看到,每一项 namespace 都附带一个编号,这是唯一标识 namespace 的,如果两个进程指向的 namespace 编号相同,则表示它们同在该 namespace 下。同时也注意到,多了一个 cgroup,这个 namespace 是 4.6 版本的内核才支持的。Docker 目前对它的支持普及度还不高。所以我们暂时先不考虑它。

下面通过简单的代码来实现 6 种 namespace 的隔离效果,让大家有个直观的印象。

UTS namespace


UTS namespace 提供了主机名和域名的隔离,这样每个容器就拥有独立的主机名和域名了,在网络上就可以被视为一个独立的节点,在容器中对 hostname 的命名不会对宿主机造成任何影响。

首先,先看总体的代码骨架:

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
#define _GNU_SOURCE
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)

static char container_stack[STACK_SIZE];
char* const container_args[] = {
"/bin/bash",
NULL
};

// 容器进程运行的程序主函数
int container_main(void *args)
{
printf("在容器进程中!\n");
execv(container_args[0], container_args); // 执行/bin/bash return 1;
}

int main(int args, char *argv[])
{
printf("程序开始\n");
// clone 容器进程
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD, NULL);
// 等待容器进程结束
waitpid(container_pid, NULL, 0);
return 0;
}

该程序骨架调用 clone() 函数实现了子进程的创建工作,并定义子进程的执行函数,clone() 第二个参数指定了子进程运行的栈空间大小,第三个参数即为创建不同 namespace 隔离的关键。

对于 UTS namespace,传入 CLONE_NEWUTS,如下:

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS, NULL);

为了能够看出容器内和容器外主机名的变化,我们子进程执行函数中加入:

1
sethostname("container", 9);

最终运行可以看到效果如下:

IPC namespace


IPC namespace 实现了进程间通信的隔离,包括常见的几种进程间通信机制,如信号量,消息队列和共享内存。我们知道,要完成 IPC,需要申请一个全局唯一的标识符,即 IPC 标识符,所以 IPC 资源隔离主要完成的就是隔离 IPC 标识符。

同样,代码修改仅需要加入参数 CLONE_NEWIPC 即可,如下:

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC, NULL);

为了看出变化,首先在宿主机上建立一个消息队列:

然后运行程序,进入容器查看 IPC,没有找到原先建立的 IPC 标识,达到了 IPC 隔离。

PID namespace


PID namespace 完成的是进程号的隔离,同样在 clone() 中加入 CLONE_NEWPID 参数,如:

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID, NULL);

效果如下,echo $$ 输出 shell 的 PID 号,发生了变化。

但是对于 ps/top 之类命令却没有改变:

原因是 ps/top 之类的命令底层调用的是文件系统的 /proc 文件内容,由于 /proc 文件系统(procfs)还没有挂载到一个与原 /proc 不同的位置,自然在容器中显示的就是宿主机的进程。

我们可以通过在容器中重新挂载 /proc 即可实现隔离,如下:

这种方式会破坏 root namespace 中的文件系统,当退出容器时,如果 ps 会出现错误,只有再重新挂载一次 /proc 才能恢复。

一劳永逸地解决这个问题最好的方法就是用接下来介绍的 mount namespace。

mount namespace


mount namespace 通过隔离文件系统的挂载点来达到对文件系统的隔离。我们依然在代码中加入 CLONE_NEWNS 参数:

1
int container_pid = clone(container_main, container_stack + STACK_SIZE, SIGCHLD | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNS, NULL);

我验证的效果,当退出容器时,还是会有 mount 错误,这没道理,经多方查阅,没有找到问题的根源(有谁知道,可以留言指出)。

Network namespace


Network namespace 实现了网络资源的隔离,包括网络设备、IPv4 和 IPv6 协议栈,IP 路由表,防火墙,/proc/net 目录,/sys/class/net 目录,套接字等。

Network namespace 不同于其他 namespace 可以独立工作,要使得容器进程和宿主机或其他容器进程之间通信,需要某种“桥梁机制”来连接彼此(并没有真正的隔离),这是通过创建 veth pair (虚拟网络设备对,有两端,类似于管道,数据从一端传入能从另一端收到,反之亦然)来实现的。当建立 Network namespace 后,内核会首先建立一个 docker0 网桥,功能类似于 Bridge,用于建立各容器之间和宿主机之间的通信,具体就是分别将 veth pair 的两端分别绑定到 docker0 和新建的 namespace 中。

和其他 namespace 一样,Network namespace 的创建也是加入 CLONE_NEWNET 参数即可。我们可以简单验证下 IP 地址的情况,如下,IP 被隔离了。

User namespace


User namespace 主要隔离了安全相关的标识符和属性,包括用户 ID、用户组 ID、root 目录、key 以及特殊权限。简单说,就是一个普通用户的进程通过 clone() 之后在新的 user namespace 中可以拥有不同的用户和用户组,比如可能是超级用户。

同样,可以加入 CLONE_NEWUSER 参数来创建一个 User namespace。然后再子进程执行函数中加入 getuid() 和 getpid() 得到 namespace 内部的 User ID,效果如下:

可以看到,容器内部看到的 UID 和 GID 和外部不同了,默认显示为 65534。这是因为容器找不到其真正的 UID ,所以设置上了最大的UID(其设置定义在/proc/sys/kernel/overflowuid)。另外就是用户变为了 nobody,不再是 root,达到了隔离。

总结


以上就是对 6 种 namespace 从代码上简单直观地演示其实现,当然,真正的实现比这个要复杂得多,然后这 6 种 namespace 实际上也没有完全隔离 Linux 的资源,比如 SElinux、cgroup 以及 /sys 等目录下的资源没有隔离。目前,Docker 在很多方面已经做的很好,但相比虚拟机,仍然有许多安全性问题急需解决。

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

Linux 是一个非常庞大的系统结构,模块众多,各个模块分工合作,共同运作着各自的任务,为 Linux 这个大家庭贡献着自己的力量。

Linux 网络子系统


其中,网络子系统是一个较为复杂的模块,如下图是 Linux 网络子系统的体系结构,自顶向下,可以分应用层、插口层(或协议无关层)、协议层和接口层(或设备驱动层),这个层级关系也分别对应着我们所熟悉的 OSI 七层模型(物理层、数据链路层、网络层、传输层、会话层、表示层和应用层)。

Linux 所有用户态的应用程序要访问链接内核都要依赖系统调用,这是内核提供给用户态的调用接口,这通常是由标准的 C 库函数来实现的。对于网络应用程序,内核提供了一套通用的 socket 系统调用的接口。

插口层,也叫协议无关层,它屏蔽了协议相关的操作,不论是什么协议(UDP,TCP),都提供一组通用的函数接口,也就是 socket 的实现。

协议层,用于实现各种具体的网络协议族,包括 TCP/IP,OSI 和 Unix 域的实现,每个协议族都包含自己的内部结构,例如,对于 TCP/IP 协议族,IP 是最底层,TCP 和 UDP 层在 IP 层的上面。

接口层,包括了通用设备接口,和网络设备通信的设备驱动程序,其中,包串口使用的 SLIP 驱动程序以及以太网使用的以太网驱动程序,以及环回口使用的都是这一层的设备。它对上提供了协议与设备驱动通信的通用接口,对下也提供了一组通用函数供底层网络设备驱动程序使用。具体的细节后面再开文章详细讲述。

以上是从分层的体系结构来看,下面从数据传输的角度来说说,一个数据包是如何依赖这些层次关系被发送/接收的。

首先,数据的发送过程,应用层组织好待发送的数据包,执行系统调用进入内核协议栈,按照层次关系分别进行包头的封装,如到达传输层,封装 TCP/UDP 的包头,进入网络层封装 IP 包头,进入接口层封装 MAC 帧,最后借助驱动将数据包从网卡发送出去。

然后,数据的接收过程正好与发包过程相反,是一个拆包的过程。接口层借助网卡 DMA 硬件中断感知数据包的到来,拆解包头,识别数据,借助软中断机制告知上层进行相应的收包处理,最终用户态执行系统调用接收数据包。

继续细化这个过程,其内部在实现上都是通过具体的数据结构来完成每一层的过渡的,譬如说,应用层和内核之间通过 struct sock 结构实现协议无关的接口调用进行交互,传输层提供 struct proto 的结构来支持多种协议,网络层通过 struct sk_buff 来传递数据,接口层定义 struct net_device 来完成和驱动程序的交互。

Linux 网络体系结构还是比较博大精深的,尤其是这套自顶向下的分层设计方式对很多技术都有借鉴意义。更具体的函数调用和数据收发过程,后面的文章再进行讲述,敬请期待。

总结


Linux 网络子系统的分层模型,数据收发过程,以及核心数据结构。

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。

文章首发于我的公众号「Linux云计算网络」,欢迎关注,第一时间掌握技术干货!

本文讲解 Linux 的零拷贝技术,我在「云计算技能图谱」中说过,云计算是一门很庞大的技术学科,融合了很多技术,Linux 算是比较基础的技术,所以,学好 Linux 对于云计算的学习会有比较大的帮助。

本文借鉴并总结了几种比较常见的 Linux 下的零拷贝技术,相关的引用链接见文后,大家如果觉得本文总结得太抽象,可以转到链接看详细解释。

为什么需要零拷贝


传统的 Linux 系统的标准 I/O 接口(read、write)是基于数据拷贝的,也就是数据都是 copy_to_user 或者 copy_from_user,这样做的好处是,通过中间缓存的机制,减少磁盘 I/O 的操作,但是坏处也很明显,大量数据的拷贝,用户态和内核态的频繁切换,会消耗大量的 CPU 资源,严重影响数据传输的性能,有数据表明,在Linux内核协议栈中,这个拷贝的耗时甚至占到了数据包整个处理流程的57.1%。

什么是零拷贝


零拷贝就是这个问题的一个解决方案,通过尽量避免拷贝操作来缓解 CPU 的压力。Linux 下常见的零拷贝技术可以分为两大类:一是针对特定场景,去掉不必要的拷贝;二是去优化整个拷贝的过程。由此看来,零拷贝并没有真正做到“0”拷贝,它更多是一种思想,很多的零拷贝技术都是基于这个思想去做的优化。

零拷贝的几种方法


原始数据拷贝操作


在介绍之前,先看看 Linux 原始的数据拷贝操作是怎样的。如下图,假如一个应用需要从某个磁盘文件中读取内容通过网络发出去,像这样:

1
2
3
while((n = read(diskfd, buf, BUF_SIZE)) > 0)

write(sockfd, buf , n);

那么整个过程就需要经历:1)read 将数据从磁盘文件通过 DMA 等方式拷贝到内核开辟的缓冲区;2)数据从内核缓冲区复制到用户态缓冲区;3)write 将数据从用户态缓冲区复制到内核协议栈开辟的 socket 缓冲区;4)数据从 socket 缓冲区通过 DMA 拷贝到网卡上发出去。

可见,整个过程发生了至少四次数据拷贝,其中两次是 DMA 与硬件通讯来完成,CPU 不直接参与,去掉这两次,仍然有两次 CPU 数据拷贝操作。

方法一:用户态直接 I/O


这种方法可以使应用程序或者运行在用户态下的库函数直接访问硬件设备,数据直接跨过内核进行传输,内核在整个数据传输过程除了会进行必要的虚拟存储配置工作之外,不参与其他任何工作,这种方式能够直接绕过内核,极大提高了性能。

缺陷:

1)这种方法只能适用于那些不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。

2)这种方法直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成资源的浪费,解决这个问题需要和异步 I/O 结合使用。

方法二:mmap


这种方法,使用 mmap 来代替 read,可以减少一次拷贝操作,如下:

1
2
3
buf = mmap(diskfd, len);

write(sockfd, buf, len);

应用程序调用 mmap ,磁盘文件中的数据通过 DMA 拷贝到内核缓冲区,接着操作系统会将这个缓冲区与应用程序共享,这样就不用往用户空间拷贝。应用程序调用write ,操作系统直接将数据从内核缓冲区拷贝到 socket 缓冲区,最后再通过 DMA 拷贝到网卡发出去。

缺陷:

1)mmap 隐藏着一个陷阱,当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,如果服务器被这样终止了,那损失就可能不小了。

解决这个问题通常使用文件的租借锁:首先为文件申请一个租借锁,当其他进程想要截断这个文件时,内核会发送一个实时的 RT_SIGNAL_LEASE 信号,告诉当前进程有进程在试图破坏文件,这样 write 在被 SIGBUS 杀死之前,会被中断,返回已经写入的字节数,并设置 errno 为 success。

通常的做法是在 mmap 之前加锁,操作完之后解锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
if(fcntl(diskfd, F_SETSIG, RT_SIGNAL_LEASE) == -1) {

perror("kernel lease set signal");

return -1;

}

/* l_type can be F_RDLCK F_WRLCK 加锁*/

/* l_type can be F_UNLCK 解锁*/

if(fcntl(diskfd, F_SETLEASE, l_type)){

perror("kernel lease set type");

return -1;

}

方法三:sendfile


从Linux 2.1版内核开始,Linux引入了sendfile,也能减少一次拷贝。

1
2
3
#include<sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

sendfile 是只发生在内核态的数据传输接口,没有用户态的参与,自然避免了用户态数据拷贝。它指定在 in_fd 和 out_fd 之间传输数据,其中,它规定 in_fd 指向的文件必须是可以 mmap 的,out_fd 必须指向一个套接字,也就是规定数据只能从文件传输到套接字,反之则不行。sendfile 不存在像 mmap 时文件被截获的情况,它自带异常处理机制。

缺陷:

1)只能适用于那些不需要用户态处理的应用程序。

方法四:DMA 辅助的 sendfile


常规 sendfile 还有一次内核态的拷贝操作,能不能也把这次拷贝给去掉呢?

答案就是这种 DMA 辅助的 sendfile。

这种方法借助硬件的帮助,在数据从内核缓冲区到 socket 缓冲区这一步操作上,并不是拷贝数据,而是拷贝缓冲区描述符,待完成后,DMA 引擎直接将数据从内核缓冲区拷贝到协议引擎中去,避免了最后一次拷贝。

缺陷:

1)除了3.4 中的缺陷,还需要硬件以及驱动程序支持。

2)只适用于将数据从文件拷贝到套接字上。

方法五:splice


splice 去掉 sendfile 的使用范围限制,可以用于任意两个文件描述符中传输数据。

1
2
3
4
5
#define _GNU_SOURCE         /* See feature_test_macros(7) */

#include <fcntl.h>

ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

但是 splice 也有局限,它使用了 Linux 的管道缓冲机制,所以,它的两个文件描述符参数中至少有一个必须是管道设备。

splice 提供了一种流控制的机制,通过预先定义的水印(watermark)来阻塞写请求,有实验表明,利用这种方法将数据从一个磁盘传输到另外一个磁盘会增加 30%-70% 的吞吐量,CPU负责也会减少一半。

缺陷:

1)同样只适用于不需要用户态处理的程序

2)传输描述符至少有一个是管道设备。

方法六:写时复制


在某些情况下,内核缓冲区可能被多个进程所共享,如果某个进程想要这个共享区进行 write 操作,由于 write 不提供任何的锁操作,那么就会对共享区中的数据造成破坏,写时复制就是 Linux 引入来保护数据的。

写时复制,就是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中,这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。

缺陷:

需要 MMU 的支持,MMU 需要知道进程地址空间中哪些页面是只读的,当需要往这些页面写数据时,发出一个异常给操作系统内核,内核会分配新的存储空间来供写入的需求。

方法七:缓冲区共享


这种方法完全改写 I/O 操作,因为传统 I/O 接口都是基于数据拷贝的,要避免拷贝,就去掉原先的那套接口,重新改写,所以这种方法是比较全面的零拷贝技术,目前比较成熟的一个方案是最先在 Solaris 上实现的 fbuf (Fast Buffer,快速缓冲区)。

Fbuf 的思想是每个进程都维护着一个缓冲区池,这个缓冲区池能被同时映射到程序地址空间和内核地址空间,内核和用户共享这个缓冲区池,这样就避免了拷贝。

缺陷:

1)管理共享缓冲区池需要应用程序、网络软件、以及设备驱动程序之间的紧密合作

2)改写 API ,尚处于试验阶段。

高性能网络 I/O 框架——netmap


Netmap 基于共享内存的思想,是一个高性能收发原始数据包的框架,由Luigi Rizzo 等人开发完成,其包含了内核模块以及用户态库函数。其目标是,不修改现有操作系统软件以及不需要特殊硬件支持,实现用户态和网卡之间数据包的高性能传递。

在 Netmap 框架下,内核拥有数据包池,发送环\接收环上的数据包不需要动态申请,有数据到达网卡时,当有数据到达后,直接从数据包池中取出一个数据包,然后将数据放入此数据包中,再将数据包的描述符放入接收环中。内核中的数据包池,通过 mmap 技术映射到用户空间。用户态程序最终通过 netmap_if 获取接收发送环 netmap_ring,进行数据包的获取发送。

总结:


1、零拷贝本质上体现了一种优化的思想

2、直接 I/O,mmap,sendfile,DMA sendfile,splice,缓冲区共享,写时复制……

参考:


(1)Linux 中的零拷贝技术,第 1 部分
https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy1/index.html
(2)Linux 中的零拷贝技术,第 2 部分
https://www.ibm.com/developerworks/cn/linux/l-cn-zerocopy2/
(3)Netmap 原理
http://www.tuicool.com/articles/MnIRbuU

PS:文章未经我允许,不得转载,否则后果自负。

–END–

欢迎扫👇的二维码关注我的微信公众号,后台回复「m」,可以获取往期所有技术博文推送,更多资料回复下列关键字获取。