Linux内核学习总结

Linux内核学习总结

1 内存管理

1.1 伙伴系统

伙伴系统是内核中用来管理物理内存的一种算法,内存中有些代码被内核代码占用,还有一些被特殊用途保留,剩下的空闲空间就会交给内核内存管理系统来统一管理和分配。内核中以页为单位对内存进行组织分配,随着进程内存的申请和释放,系统中的内存会不断的区域碎片化,导致系统虽然还有很多空闲的内存,但是却不能分配出一块连续的内存,于是出现了伙伴系统算法来缓解这种碎片化。
伙伴系统(buddy system)把系统中要管理的物理内存按照页面个数分为11个不同的组,分别对应11种大小不同的连续的内存块,每组中的内存块大小相等,为2的幂次个物理页。那么系统中就存在2^0 ~ 2^10这么11种大小不同的内存块,对应内存块的大小为4KB ~ 4KB*2^10,也就是4KB到4M,而内核用11个链表来管理这11种大小不同的内存块。
内存的分配:
当内存分配内存时,会优先从需要分配的内存块链表上查找空闲内存块,当发现对应大小的内存块都已经被使用后,那么会从更大一级的内存块上分配一块内存,并且分成一半给我们使用,剩余的一半释放到对应大小的内存块链表上。比如我们想要分配一块8KB大小的内存,但是发现同等大小的内存已经没有了,这时候就去查找16KB的空闲内存块,并分成两个8KB,把其中一个分给我们使用,另外一个释放到8KB的链表上进行管理。万一16KB的空闲内存块也没有了,就会到32KB的链表中查找空闲内存块,查到后分成两个16KB,并将其中一个继续分成两个8KB供我们使用,同时将另外一个16KB的内存块挂到16KB的链表中,8KB的内存块挂到8KB的链表中进行管理。
内存的释放:
当释放内存时,首先会扫描对应大小的内存块链表,查看是否存在地址连续在一起的内存块,如果有的话就将这两个内存块合并成一个,并放置到更大一级的内存块链表上,以此类推。比如我们释放一个8KB大小的内存,那么就会去8KB链表中扫描是否有能够合并的内存块,如果有另一个8KB大小的内存和我们使用的内存地址连续,那么就将它们合并成一个16KB大小的内存块,然后放到16KB内存块链表中,继续扫描16KB大小的内存块链表,继续查找。

1.2 页和页表

页也称为页帧,在内核中,内存管理单元MMU(负责虚拟地址到物理地址转换的硬件单元)是把物理页PAGE作为内存管理的基本单位。页的大小跟体系结构有关,通常32位体系结构支持4KB的页,64位体系结构支持8KB的页大小,可通过如下命令获取当前主机支持的页的大小:
getconf PAGE_SIZE
内核使用struct page 结构来表示系统中的每个物理页,结构信息如下所示:
struct page {
unsigned long flags; //表示当前页的状态,包括页是不是脏的,是否被锁定等
atomic_t__count; //存放当前页的引用计数,代码中可以通过page_count()获取,返回0表示页空闲
struct address_space *mapping;

pa-ff_t index;
struct list_head lru;
void *virtual; //页对应的虚拟地址
}
注意:page结构与物理页相关,而非与虚拟页相关。
通过这个结构体,我们在内核中不仅可以知道一个页是否为空闲的,还可以知道这个页的拥有者是谁,拥有者可能是用户空间进程、动态分配的内核数据、静态内核代码或者页高速缓存等。
脏页:如果与硬盘上的数据相比,页的内容已经改变,则置位PG_dirty,设置了该标志的页为脏页。

先说一下虚拟地址相关的,创建一个进程时,32位的操作系统会为进程分配一个4GB大小的虚拟地址空间,之所以是4GB,是因为32位操作系统,一个指针长度是32位,也就是4个字节(一个字节是8位),那么2的32次方个地址寻址能力就是从0x00000000~0xFFFFFFFF,即为4GB大小。这里的4GB指的最大寻址空间是4G,实际上一个进程用到的虚拟地址空间用不了4G,而用到的内存区域会通过页表映射到物理内存,所以每个进程用相同的虚拟地址也不会冲突,因为它们的物理地址是不同的。32位的Linux下,一个进程空间是4G,然而这4G也不是全部给用户使用的,内核空间占1G,用户空间占3G。
继续说页表,由伙伴系统我们知道分配物理内存的页是以4KB为单位的,也就是最小的物理内存块为4KB,那么我们在虚拟地址中也以4KB(一页)为单位转换成物理地址,这时候就需要页表来记录每一块物理内存的首地址和虚拟地址之间的映射关系,以4KB为单位将虚拟地址转换成物理地址。
介绍页表之前先明确几个基本的概念:
页:将进程划分成的块,对应的大小称为页面大小
页框:将内存划分成的块
页和页框是一一对应的关系,理论上页的大小跟页框(物理内存块)的大小相等。
页表:页和页框一一对应的关系表,这个关系表存放在内存中,只是起到一个索引的作用,通过关系表可以查到某一个页面和哪一个页框对应。
以4K为例,一个4G的虚拟地址空间共产生2^20个页,每一个页表项存储一个页和一个页框的映射,
所以共需要1M(2^ 20)个页表项(页表项=页面数),每个页表项大小为4B(32位),共需要占用4B*2^20=4M的空间来存储页表项。
在这里插入图片描述
总的来说页表的作用就是将页面跟页框(物理内存块)一一对应起来,所以每一个页面对应一个页框。那么为什么页框号地址为12位,只能表示2^ 12个页框,要小于2^20个页面呢,因为并不是进程的每一个页面都要调入内存。其实32位、12位、20位这三个数据还是有一定依据的,在二级分页的时候就会发现“刚刚好”。

一级页表:
在这里插入图片描述
在进程不被CPU执行的时候,上图描述的一级页表中页表起始地址和页表长度存放在PCB(进程控制块)中。可以发现,一级页表中,CPU对内存的一次访问动作需要访问两次物理内存才能达到目的,第一次,拿到页框的起始地址,第二次,访问最终的物理地址。由此可见,一级页表下CPU的效率变成了50%,为了提高CPU对内存的访问效率,在CPU第一次访问内存之前,加一个快速缓冲区(TLB)寄存器,它里面存放了近期访问过的页表项,当CPU发起一次访问时,先到TLB中查询是否存在该页表项,如果存在就直接返回了,也就是只访问一次物理内存就达到目的,将CPU对内存的访问效率提高到了接近90%,该过程如下图所示:
在这里插入图片描述
然而这种方式还是存在弊端,在物理内存中需要拿出来4M的连续内存空间来存放页表,所以可以通过多级页表的方式,将页表继续分为多个部分,这样就不要求连续的整段内存,只需要多个连续的小段内存即可。
将页表分为分为1024个表,每个表中包含1024个页表项,形成二级页表,可以将一级页表理解成书的目录,可以根据每个目录去查找相应的章节。CPU将从基地址寄存器中拿到一级页表的地址,从地址结构中取出一级页表的页表号,找到二级页表的起始物理地址,然后结合地址结构中的中间10位即二级页表号,可以找到相应的框的起始地址,最后结合页内的偏移量,就可以计算出最终要访问的物理地址,总共需要访问3次。
在这里插入图片描述在这里插入图片描述
这里需要说明一下,虽然CPU通过二级页表访问物理内存的次数增加到了三次,但是可以大大节省内存空间,从进程的角度看,二级页表只需要为实际使用的虚拟内存区请求页表,未使用的页暂时可以不用为其建立页表,这样就减少了页表项,每个页表项的大小是4B,如果是采用一级页表,必须为所有的4G空间进行页表的分配,也就是需要占用4B2^ 20=4M内存空间;而采用二级页表的话,一级页目录表中有2^10(1024)个页目录项,每个页目录项大小为4B,共占用4B1024=4K内存空间,二级页表中有1024个页表项,共占用4B*1024=4K内存空间,这样占用内存的总和大小为8K,相比于4M是大大节省了内存资源。

1.3 slab分配器

之前提到页框分配器,主要是管理内存需要,将物理内存的页框分配给申请者,并且我们知道页框的大小最小为4K,最大可为4M,这时候就出现一个问题,假如我只需要1K大小的内存,页框分配器分配给我的页框大小也只能是4K,那么有3K大小的内存就浪费掉了。所以为了应对这种情况的发生,在页框分配器上一层又做了一层slab层,slab分配器的作用就是从页框分配器中拿出一些页框,专门把这些页框拆分成一小块一小块的内存,当申请者申请的是小内存时,系统就从slab中取以小块分配给申请者,它们的整个关系如下图所示:
在这里插入图片描述可以看出,slab分配器和页框分配器并没有直接的联系,对于页框分配器来说,slab分配器也只是一个从它那里申请页框的申请者而已。
在slab分配器中将slab分为两大类:专用slab和普通slab。专用的slab用于特定场合比如(TCP有自己专用的slab,当TCP模块需要小内存时,会从自己的slab中分配),而普通slab用于常规分配的场合。这里可以使用命令查看slab的状态:
cat /proc/slabinfo
命令结果如下:
在这里插入图片描述在这里插入图片描述这里我们看到第一张图上有一些slab分配器的名字比较特别,为TCP、UDP、dquot等这些,它们都是专用的slab,专属于它们自己的模块。第二张图上有kmalloc-8、kmalloc-16以及dma-kmalloc-96、dma-kmalloc-192…这些都是普通的slab,当申请一些小内存时,就会从这些普通的slab中获取内存。值得注意的是,对于kmalloc-8这些普通的slab,都有一个对应的dma-kmalloc-8这种类型的普通slab,这种类型是专门使用了ZONE-DMA区域的内存,方便用于DMA模式下申请内存。
在slab中,可分配的内存块称之为对象,在第二张图中,如kmalloc-8这个普通的slab,里面所有的对象都是8B大小,同理,kmalloc-16中的对象都是以16B为大小,当你申请1B8B内存时,系统就会从kmalloc-8这个slab分配器中分配一个对象给你,当你申请8B16B的内存时,系统就会从kmalloc-16中给你分配。虽然即使申请5B内存,分配了一个8B的对象,还有3B空闲会浪费掉,但是这也已经大大减小了内存碎片化了,保证了碎片内存不会超过50%,需要注意的是,在kmalloc-8中申请到的对象,释放时也会回到kmalloc-8中。除了减小了内存碎片化问题,slab还有一个作用就是提高了系统的效率,当对象拥有者释放一个对象后,slab的处理仅仅标记对象为空闲,并不做多少处理,而又有申请者申请相应大小的对象时,slab会优先分配最近释放的对象,这样这个对象甚至有可能还在硬件高速缓存中,有点类似管理区页框分配器中每个CPU高速缓存的做法。
kmem_cache结构:
虽然叫slab分配器,但是在slab分配器中,最顶层的数据结构却不是slab,而是kmem_cache。我们暂且叫它slab缓存吧,每个slab缓存都有它自己的名字,就是上图中的kmalloc-8,kmalloc-16等。总的来说,kmem_cache结构用于描述一种slab,并且管理kmem_cache_create自行创建一个kmem_cache用于管理属于自己模块的slab。先看下kmem_cache结构:

//slab分配器中的slab高速缓存
struct kmem_cache {
//指向包含空闲对象的本地缓存,每个CPU有一个该结构,当有对象释放时,优先存入本地cpu高速缓存中
struct array_cache __percpu *cpu_cache;

//要转移进本地高速缓存或者本地高速缓存中转移出去的对象的数量
unsigned int batchcount;

//本地高速缓存中空闲对象的最大数目
unsigned int limit;

//是否存在CPU共享高速缓存,CPU共享高速缓存指针保存在kmem_cache_node结构中
unsigned int shared;

//对象长度和填充字节
unsigned int size;

//size的倒数,加快计算
struct reciprocal_value reciprocal_buffer_size;

//高速缓存永久属性的标识,如果slab描述符放在外部(不在slab中),则CFLAGS_OFF_SLAB置1
unsigned int flags;

//每个slab中对象的个数(在同一个高速缓存中slab对象个数相同)
unsigned int num;

//一个单独slab中包含的连续页框数目的对数
unsigned int gfporder;

//分配页框时传递给伙伴系统的一组标识
gfp_t allocflags;

//slab中使用的颜色的个数
size_t colour;

//slab中基本对齐偏移,当新的slab着色时,偏移量的值需要乘上这个基本对齐偏移。理解就是1个偏移量等于多少个B大小的值
unsigned int colour_off;

//空闲对象链表放在外部使用,指向的slab高速缓存来存储空闲对象链表
struct kmem_cache *freelist_cache;

//空闲对象链表的大小
unsigned int freelist_size;

//构造函数,一般用于初始化这个slab高速缓存中的对象
void (*ctor)(void *obj);

//存放高速缓存的名字
const char *name;
//高速缓存描述符双向链表指针
struct list_head list;
int refcount;
//高速缓存中对象的大小
int object_size;
int align;

//结构链表,此高速缓存可能在不同的NUMA的节点上都有slab链表
struct kmem_cache_node *node[MAX_NUMNODES];
};
从结构中可以看出,这个kmem_cache中所有对象的大小是相同的(object_size),并且此结构中所有slab的大小也是相同的(gfporder、num)。在这个结构中最重要的可能就是struct kmem_cache_node *node[MAX_NUMNODES]这个指针数组了,指向的 kmem_cache_node中保存着slab链表,在NUMA架构中每个node对应数组中的一个元素,因为每个slab高速缓存都有可能在不同结点维护有自己的slab用于这个结点的分配。下面看下struct kmem_cache_node:
//slab链表结构
struct kmem_cache_node {
//锁
spinlock_t list_lock;
//slab用
#ifdef CONFIG_SLAB
//只使用了部分对象的slab描述符的双向循环链表
struct list_head_ slabs_partial;
//不包含空闲对象的slab描述符的双向循环链表
struct list_head slabs_full;
//只包含空闲对象的slab描述符的双向循环链表
struct list_head slabs_free;
//高速缓存中空闲对象的个数
unsigned long free_objects;
//高速缓存中空闲对象的上限
unsigned long free_limit;
//下一个被分配的slab使用的颜色
unsigned int colour_next;
//指向这个节点上所有CPU共享的一个本地高速缓存
struct array_cache *shared;
struct alien_cache **alien;
//两次缓存收缩时的间隔,降低次数,提高性能
unsigned long next_reap;
//0:收缩 1:获取一个对象
int free_touched;
#endif

//slub用
#ifdef CONFIG_SLUB
unsigned long nr_partial;
struct list_head partial;
#ifdef CONFIG_SLUB_DEBUG
atomic_long_t nr_slabs;
atomic_long_t total_objects;
struct list_head full;
#endif
#endif
};
在这个结构中,最重要的就是slabs_partial、slabs_full、slabs_free这三个链表头。
slabs_partial:维护部分对象被使用了的slab链表,保存的是slab描述符。
slabs_full:维护所有对象都被使用了的slab链表,保存的是slab描述符。
slabs_free:维护素有对象都空闲的slab链表,保存的是slab描述符。
slab就是一组连续的页框,它的描述符结合在页描述符中,也就是页描述符描述slab的时候,就是slab描述符,这三个链表保存的就是这组页框的首页框的slab描述符。链表的组织形式与伙伴系统的组织页框的形式一样。刚开始创建kmem_cache完成后,三个链表都为空,只有在申请对象时发现没有可用的slab时才会创建一个新的slab,并加入到这三个链表中的一个,也就是说slab数量是动态变化的,当slab数量太多时,kmem_cache会将一些slab释放回页分配器中。下面看下在page结构体中,有关slab的描述:
struct page {
/* First double word block /
/
用于页描述符,一组标志(如PG_locked、PG_error),也对页框所在的管理区和node进行编号 /
unsigned long flags; /
union {
/
用于页描述符,当页被插入页高速缓存中时使用,或者当页属于匿名区时使用 */
struct address_space mapping;
/
用于SLAB描述符,指向第一个对象的地址 */
void s_mem; / slab first object /
};
/
Second double word /
struct {
union {
/
作为不同的含义被几种内核成分使用。例如,它在页磁盘映像或匿名区中标识存放在页框中的数据的位置,或者它存放一个换出页标识符 /
pgoff_t index; /
Our offset within mapping. /
/
用于SLAB描述符,指向空闲对象链表 */
void freelist;
/
当管理区页框分配器压力过大时,设置这个标志就确保这个页框专门用于释放其他页框时使用 /
bool pfmemalloc;
};
union {
#if defined(CONFIG_HAVE_CMPXCHG_DOUBLE) &&
defined(CONFIG_HAVE_ALIGNED_STRUCT_PAGE)
/
Used for cmpxchg_double in slub /
/
SLUB使用 /
unsigned long counters;
#else
/
SLUB使用 /
unsigned counters;
#endif
struct {
union {
/
页框中的页表项计数,如果没有为-1,如果为PAGE_BUDDY_MAPCOUNT_VALUE(-128),说明此页及其后的一共2的private次方个数页框处于伙伴系统中,正在使用时应该是0 /
atomic_t _mapcount;
struct { /
SLUB使用 /
unsigned inuse:16;
unsigned objects:15;
unsigned frozen:1;
};
int units; /
SLOB /
};
/
页框的引用计数,如果为-1,则此页框空闲,并可分配给任一进程或内核;如果大于或等于0,则说明页框被分配给了一个或多个进程,或用于存放内核数据。page_count()返回_count加1的值,也就是该页的使用者数目 /
atomic_t _count; /
Usage count, see below. /
};
/
用于SLAB时描述当前SLAB已经使用的对象 /
unsigned int active; /
SLAB /
};
};
/
Third double word block /
union {
/
包含到页的最近最少使用(LRU)双向链表的指针,用于插入伙伴系统的空闲链表中,只有块中头页框要被插入。也用于SLAB,加入到kmem_cache中的SLAB链表中 /
struct list_head lru;
/
SLAB使用 /
struct { /
slub per cpu partial pages */
struct page next; / Next partial slab /
#ifdef CONFIG_64BIT
int pages; /
Nr of partial slabs left /
int pobjects; /
Approximate # of objects /
#else
short int pages;
short int pobjects;
#endif
};
/
SLAB使用 */
struct slab slab_page; / slab fields /
struct rcu_head rcu_head; /
Used by SLAB
* when destroying via RCU
/
#if defined(CONFIG_TRANSPARENT_HUGEPAGE) && USE_SPLIT_PMD_PTLOCKS
pgtable_t pmd_huge_pte; /
protected by page->ptl /
#endif
};
/
Remainder is not double word aligned /
union {
/
可用于正在使用页的内核成分(例如: 在缓冲页的情况下它是一个缓冲器头指针,如果页是空闲的,则该字段由伙伴系统使用,在给伙伴系统使用时,表明的是块的2的次方数,只有块的第一个页框会使用) */
unsigned long private;
#if USE_SPLIT_PTE_PTLOCKS
#if ALLOC_SPLIT_PTLOCKS
spinlock_t ptl;
#else
spinlock_t ptl;
#endif
#endif
/
SLAB描述符使用,指向SLAB的高速缓存 */
struct kmem_cache slab_cache; / SL[AU]B: Pointer to slab */
struct page first_page; / Compound tail pages */
};

#if defined(WANT_PAGE_VIRTUAL)
/* 线性地址,如果是没有映射的高端内存的页框,则为空 */
void virtual; / Kernel virtual address (NULL if
not kmapped, ie. highmem) /
#endif /
WANT_PAGE_VIRTUAL /
#ifdef CONFIG_WANT_PAGE_DEBUG_FLAGS
unsigned long debug_flags; /
Use atomic bitops on this */
#endif
#ifdef CONFIG_KMEMCHECK
void *shadow;
#endif
#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
int _last_cpupid;
#endif
}
在slab描述符中,最重要的就是s_mem和freelist这两个指针,s_mem用于指向这段连续页框中的第一个对象,freelist指向空闲对象链表。空闲对象链表是由一个数组制成的简单链表,它保存的地方有两种情况:
1、保存在外部,会从slab中分配一个对象用于保存新的slab的空闲对象链表。
2、保存在内部,保存在这个slab所代表的连续页框的头部。
不过一般没有其他情况的话,空闲对象链表都是保存在内部。这里我们只讨论将空闲对象链表保存在内部的情况,这种情况下,这个slab所代表的连续页框的头部首先放的就是空闲对象链表,后面接着放的是对象描述符数组(1-2个字节大小),之后紧跟着就是对象所代表的内存了,如下图所示:
在这里插入图片描述我们看下这个freelist数组是怎么形成一个链表的,之前我们也说了分配时会优先分配最近释放的对象,整个freelist跟struct page中的active有跟大关系,可以说active决定了下个分配对象是谁,在freelist数组制作成的链表中,active作为下标,保存目标空闲对象的对象号,在活动过程中,会动态修改这个数组中的值。可用下图看清楚freelist是如何实现的:
在这里插入图片描述slab中的连续页框个数与kmem_cache结构中的gfporder(一个单独SLAB中包含的连续页框数目的对数)有关,而这个gfporder在初始化时通过对象数量、大小、freelist大小、对象描述符数组大小和着色区计算出来的。而对于对象的大小,也并不是我们创建时打算使用的大小,比如,我打算创建一个kmem_cache的对象大小时10个字节,而创建过程中,系统会去优化和初始化这些对象,包括该对象保存地址放在内存对齐标志,在对象的两边放入一些填充区域(RED_ZONE)进行防止越界等工作。
slab着色:
我们知道内存需要处理时需要先放入CPU硬件高速缓存中,而CPU硬件高速缓存与内存的映射方式有多种,
在同一个kmem_cache结构体中所有的slab都是大小相同,都是相同连续长度的页框组成,这样的话在不同的slab中相同对象号对于页框的首地址的偏移量也相同,这样很可能导致不同slab中相同对象号的对象放入CPU硬件高速缓存时会处于同一行,这样当我们交替操作这两个对象时,CPU的cache就会交替换入换出,效率会很低,slab着色就是在同一个kmem_cache中对不同的slab添加一个偏移量,让相同对象号的对象不会对齐,也就不会放入硬件高速缓存的同一行中,提高了效率。如下图所示:
在这里插入图片描述着色空间就是前端的空闲区域,这个区域的大小都是在分配新的slab时计算好的,计算方法很简单,node节点对应的kmem_cache_node中colour_next乘上kmem_cache中的colour_off就得到了偏移量,然后colour_next++,当colour_next等于kmem_cache中的colour时,colour_next回归到0。
偏移量 = kmem_cache.colour_off * kmem_cache.node[NODE_ID].colour_next;

kmem_cache.node[NODE_ID].colour_next++;
if (kmem_cache.node[NODE_ID].colour_next == kmem_cache.colour)
    kmem_cache.node[NODE_ID].colour_next = 0;

本地CPU空闲对象链表
本地CPU空闲对象链表中kmem_cache结构中用cpu_cache表示,整个数据结构时struct array_cache,它的目的是将释放的对象加入到这个链表中,可以先看下数据结构:
struct array_cache {
/* 可用对象数目 /
unsigned int avail;
/
可拥有的最大对象数目,和kmem_cache中一样 /
unsigned int limit;
/
同kmem_cache,要转移进本地高速缓存或从本地高速缓存中转移出去的对象的数量 /
unsigned int batchcount;
/
是否在收缩后被访问过 /
unsigned int touched;
/
伪数组,初始没有任何数据项,之后会增加并保存释放的对象指针 */
void entry[]; /
};
每个CPU都有自己的硬件高速缓存,当CPU释放对象时,虽然对象被释放了,但是可能还在这个CPU的硬件高速缓存中,所以内核为每个CPU维护这样一个本地CPU空闲链表,当需要新的对象时,会优先从当前CPU的本地空闲链表中获取相应大小的对象,该链表在系统初始化时是一个空的链表,只有释放对象时才会将对象加入这个链表,当然链表的对象个数也是有限制的,最大值就是limit,链表数超过这个值时,会将batchout个数的对象返回到所有的CPU共享空闲对象链表中。注意,array_cache中有一个entry数组,里面保存的是指向空闲对象的首地址的指针,注意这个链表是在kmem_cache结构中,也就是kmalloc-8有它自己的本地cpu高速缓存链表,dquot也有它自己的本地CPU高速缓存链表,每种类型的kmem_cache都有它自己的本地CPU空闲对象链表。
所有CPU共享的空闲对象链表同本地CPU空闲对象链表原理一样,唯一的区别就是所有CPU都可以从这个链表中获取对象,一个常规的对象申请流程是这样的:
系统首先会从本地CPU空闲对象链表中尝试获取一个对象用于分配,如果失败,则尝试从所有CPU空闲对象链表中获取,如果继续失败,就会从slab中分配一个,这时如果还是失败,kmem_cache会尝试从页框分配器中获取一组连续的页框建立一个新的slab,然后从新的slab中获取一个对象。对象释放过程也类似,首先会将对象释放到本地CPU空闲对象链表中,如果本地CPU空闲对象链表中对象过多,超过了limit值,则kmem_cache会将本地CPU空闲链表中的batchcount个对象移动到所有CPU共享的空闲对象链表中,如果所有CPU共享的空闲对象链表中对象也太多了,kmem_cache也会把所有CPU共享的空闲对象链表中batchcount个对象移回它们自己所属的slab中,这时候如果slab中空闲对象太多,kmem_cache会整理出一些空闲的slab,将这些slab所占用的页框放回页框分配器中。
注意:所有CPU共享空闲对象链表是否存在是由kmem_cache中的shared值决定的,shared值为1则有这个高速缓存,为0就没有这个高速缓存。整个slab框架如下图所示:
在这里插入图片描述

1.4 缺页中断

缺页中断,又称硬错误,硬中断,分页错误,寻址缺失,缺页中断,页故障等,指的是当软件试图访问已经映射在虚拟地址空间中,但目前并未被加载到物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。如果操作系统判断此次访问是有效的,那么操作系统就会尝试将相关的分页从硬盘上的虚拟内存文件中调入内存;如果访问是不被允许的,那么系统就会结束相关的进程。将新页调入内存时,如果内存中所有的物理页都已经分配出去,就按照某种策略废弃整个页面,将其所占据的物理页释放出来。
产生缺页中断的几种情况:
1、当内存管理单元MMU中确实没有创建虚拟物理页的映射关系,并且在该虚拟地址之后再没有当前进程的线性区(vma)的时候,可以肯定这是一个编码错误,将杀掉该进程。
2、当MMU中确实没有创建虚拟页物理映射关系,并且在该虚拟地址之后存在当前线程的vma的时候,这很可能是栈溢出导致的缺页中断。
3、当使用malloc/mmap等希望访问物理空间的库函数/系统调用后,由于Linux并未真正给新创建的vma映射物理页,此时若先进行写操作,将和2产生缺页中断情况一样;若先进行读操作,虽然也会产生缺页异常,
但该情况下会先将被映射给默认的零页,等再进行写操作时,仍会产生缺页中断,并分配物理页,进入写时复制的流程。
4、当用fork等系统调用创建子进程时,子进程不论有没有自己的vma,它的vma都有对于物理页的映射,只不过它们共同映射的这些物理页属性为只读,即Linux并未给子进程真正分配物理页,当父子进程任何一方要写相应的物理页时,导致缺页中断的写时复制。
查看进程发生缺页中断的次数命令:
ps -o majflt,minflt -C program
majflt和minflt表示一个进程自启动以来发生的缺页中断次数。
缺页中断的处理:
缺页中断处理函数为arch/arm/mm/fault.c文件中的do_page_fault函数
1、当前执行流程在内核态时
在这里插入图片描述
(1)通过mm是否存在判断是否是内核线程,对于内核线程,进程描述符的mm总是NULL,一旦成立,说明是在内核态中发生的异常,跳到no_context;
if(in_atomic() || !mm)
goto no_context;
如果当前执行流程在内核态,不论是在临界区还是内核进程本身(内核的mm为NULL),说明在内核态出了问题,跳到标号no_context进入内核态异常处理,由函数_do_kernel_fault完成:
在这里插入图片描述

这个函数首先尽可能的去解决这个异常,通过查找异常表中和目前的异常对应的解决办法并调用执行,如果无法通过异常表解决,那么内核就要在打印其页表等内容后退出。
在这里插入图片描述
在这里插入图片描述
(2)用户进程的缺页中断
对于用户空间的缺页中断,则会调用函数_do_page_fault
首先从CPU的控制寄存器CR2中读出出错的地址address,然后调用find_vma(),在进程的虚拟地址空间中找出结束地址大于address的第一个区间,如果找不到的话,则说明中断是地址越界引起的,转到bad_area执行相关错误处理;确定并非地址越界后,控制转向标号good_area。在这里,代码首先对页面进行例行权限检查,比如当前的操作是否违反该页面的Read,Write,Exec权限等,如果通过检查,则进入虚拟管理例程handle_mm_fault,否则将与地址越界一样,转到bad_area继续处理。
handle_mm_fault()用于实现页面的分配与交换,它分为两个步骤:首先,如果页表不存在或者被交换出,则要首先分配页面给页表,然后才真正的分配页面,并在页表上做记录。分配页框这个动作是调用handle_pte_fault()来完成的。
handle_pte_fault()函数根据页表项pte所描述的物理页框是否在内存中,分为两大类:
(1)请求调页:被访问的页框不在主存中,那么此时必须分配一个页框,分为线性映射、非线性映射、swap情况下映射
(2)写时复制:被访问的页存在,但是该页是只读的,内核需要对该页进行写操作,此时内核将这个已存在的只读页中的数据复制到一个新的页框中
handle_pte_fault()调用pte_non()检查表项是否为空,即全为0;如果为空就说明映射尚未建立,此时调用do_no_page()来建立内存页面与交换文件的映射,反之,如果表项非空,说明页面已经映射,只要调用do_swap_page()将其换入内存即可。
缺页中断与一般中断的区别与联系:
在请求分页系统中,可以通过查询页表中的状态位来确定所要访问的页面是否存在与内存中,每当所要访问的页面不在内存时,会产生一次缺页中断,此时操作系统会根据页表中的外存地址在外存中找到所缺的一页,
将其调入内存。缺页本身就是一种中断,与一般的中断一样,需要经过4个处理步骤:
1、保护CPU现场
2、分析中断原因
3、转入缺页中断处理程序进行处理
4、恢复CPU现场,继续执行
但是在缺页中断时,由于所要访问的页面不存在于内存,而是由硬件产生的一种特殊的中断,因此,与一般中断存在区别:
1、在指令执行期间产生和处理缺页中断信号
2、一条指令在执行期间,可能产生多次缺页中断
3、缺页中断返回时,继续执行产生中断的那一条指令,而一般的中断返回时,会执行下一条指令
进程运行过程中,如果发生缺页中断,而此时内存中又没有空闲的物理块,为了能够把所缺的页面装入内存,系统必须从内存中选择一页调出到磁盘的对换区,但此时应该把哪个页面换出,则需要根据一定的页面置换算法(Page Replacement Algorithm)来确定,下面简单介绍一下页面置换的几种算法:
1、最佳置换(OPT),置换以后不再被访问,或者在将来最迟才会被访问的页面,缺页中断率最低。但是该算法需要依据以后各页的使用情况,而当一个进程还未运行完成时,很难估计哪一个页面以后不再使用或者在最长时间以后才会用到的页面,所以该算法是不能实现的,但是该算法仍然有意义,作为衡量其他算法优劣的一个标准。
2、先进先出置换算法(FIFO),置换最先调入内存的页面,即置换在内存中驻留时间最久的页面。按照进入内存的先后次序排成队列,从队尾进入,从队首删除,但是该算法会淘汰经常访问的页面,不适应进程实际运行的规律,目前已经很少使用。
3、最近最久未使用置换算法(LRU),置换最近一段时间以来最长时间未访问的页面。根据程序局部性原理,刚被访问的页面,可能马上又要被访问;而较长时间内没有被访问的页面,可能最近不会被访问。LRU算法普遍地适用于各种类型的程序,但是系统要时时刻刻对各页的访问历史情况加以记录和更新,开销太大,因此LRU算法必须要有硬件的支持。

1.5 反向映射

为了系统的安全性,Linux内核将各个用户进程运行在各自独立的虚拟地址空间,用户进程之间通过虚拟地址空间相互隔离,不能相互访问,一个进程的奔溃不会影响到整个系统的异常也不会干扰到系统以及其他进程运行。Linux内核可以通过共享内存的方式为系统节省大量内存,例如fork子进程的时候,父子进程通过只读的方式共享所有的私有页面。再比如通过IPC共享内存方式,各个不相干的进程直接可以共享一块物理内存等等。我们都知道操作系统开启mmu之后cpu访问到的都是虚拟地址,当cpu访问一个虚拟地址的时候需要通过mmu将虚拟地址转化为物理地址,这叫做正向映射。而与本文相关的是反向映射,它主要是通过物理页来找到共享这个页的所有的vma对应的页表项。
在这里插入图片描述1.反向映射的发展
实际上在早期的Linux内核版本中是没有反向映射的这个概念的,那个时候为了找到一个物理页面对应的页表项就需要遍历系统中所有的mm组成的链表,然后对于每一个mm再遍历每一个vma,然后查看这个vma是否映射了这页,这个过程极其漫长而低效,有的时候不得不遍历完所有的mm然后才能找映射到这个页的所有pte。
在这里插入图片描述后来人们发现了这个问题,就再描述物理页面的page结构体中增加一个指针的方式来解决,通过这个指针来找到一个描述映射这个页的所有pte的数组结构,这对于反向映射查找所有pte易如反掌,但是带来的是浪费内存的问题。
在这里插入图片描述接着就在2.6内核的时候,内核大神们想到了复用page结构中的mapping字段,然后通过红黑树的方式来组织所有映射这个页的vma,形成了匿名页和文件页的反向映射机制。
如下为匿名页反向映射图解:
在这里插入图片描述如下为文件页反向映射图解:
在这里插入图片描述但是后来匿名页的反向映射遇到了效率和锁竞争激烈问题,就促使了目前使用的通过avc的方式联系各层级反向映射结构然后将锁的粒度降低的这种方式。可以看到反向映射的发展是伴随着Linux内核的发展而发展,是一个不断进行优化演进的过程。
2.反向映射应用场景
那么为何在Linux内核中需要反向映射这种机制呢?它究竟为了解决什么样的问题而产生的呢?
试想有如下场景:
(1)一个物理页面被多个进程的vma所映射,系统过程中发生了内存不足,需要回收一些页面,正好发现这个页面是适合我们回收利用的,我们能够直接把这个页面还给伙伴系统吗?答案肯定是不能。因为这个页面被很多个进程所共享,我们必须做的事情就是断开这个页面的所以映射关系,这就是反向映射所做的事情。
(2)一些情况我们需要将一个页面迁移到另一个页面,但是牵一发而动全身,可能有一些进程已经映射这个即将要迁移的页面到自己的vma中,那么这个时候同样需要我们知道究竟这个页面被哪些vma所映射呢?这同样是反向映射所做的事情。
实际上,反向映射的主要应用场景为内存回收和页面迁移,当系统发生内存回收和页面迁移的时候,对于每一个候选页Linux内核都会判断是否为映射页,如果是,就会调用try_to_unmap 来解除页表映射关系,本文也主要来从try_to_unmap函数来解读反向映射机制。
如果我们在细致到其他的内核子系统会发现,在内存回收,内存碎片整理,CMA, 巨型页,页迁移等各个场景中都能发现反向映射所做的关键性的工作,所有理解反向映射机制在Linux内核中的实现是理解掌握这些子系统的基础和关键性所在,否则你即将不能理解这些技术背后的脊髓所在,所以说理解反向映射这种机制对于理解Linux内核内存管理是至关重要的!!!
3.匿名页的反向映射
匿名页的共享主要发生在父进程fork子进程的时候,父fork子进程时,会复制所有vma给子进程,并通过调用dup_mmap->anon_vma_fork建立子进程的rmap以及和长辈进程rmap关系结构:
在这里插入图片描述主要通过anon_vma这个数据结构体中的红黑树将共享父进程的页的所有子进程的vma联系起来(通过anon_vma_chain 来联系对应的vma和av),当然这个关系建立比较复杂,涉及到vma,avc和av这些数据结构体。.
而在缺页异常do_anonymous_page的时候将page和vma相关联。
当内存回收或页面迁移的时候,内核路径最终会调用到:
try_to_unmap //mm/rmap.c
->rmap_walk
->rmap_walk_anon
->anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root,pgoff_start, pgoff_end)
->rwc->rmap_one
->try_to_unmap_one

对于候选页,会拿到候选页相关联的anon_vma,然后从anon_vma的红黑树中遍历到所有共享这个页的vma,然后对于每一个vma通过try_to_unmap_one来处理相对应的页表项,将映射关系解除。
4.文件页的反向映射
文件页的共享主要发生在多个进程共享libc库,同一个库文件可以只需要读取到page cache一次,然后通过各个进程的页表映射到各个进程的vma中。
管理共享文件页的所以vma是通过address_space的区间树来管理,在mmap或者fork的时候将vma加入到这颗区间树中:
在这里插入图片描述发生文件映射缺页异常的时候,将page和address_space相关联。
当内存回收或页面迁移的时候,内核路径最终会调用到:
try_to_unmap //mm/rmap.c
->rmap_walk
->rmap_walk_file
->vma_interval_tree_foreach(vma, &mapping>i_mmap,pgoff_start, pgoff_end)
->rwc->rmap_one

对于每一个候选的文件页,如果是映射页,就会遍历page所对应的address_space的区间树,对于每一个满足条件的vma,调用try_to_unmap_one来找到pte并解除映射关系。
5.ksm页的反向映射
ksm机制是内核将页面内容完全相同的页面进行合并(ksm管理的都是匿名页),将映射到这个页面的页表项标记为只读,然后释放掉原来的页表,来达到节省大量内存的目的,这对于host中开多个虚拟机的应用场景非常有用。
ksm机制中会管理两课红黑树,一棵是stable tree,一棵是unstable tree,stable tree中的每个节点stable_node中管理的页面都是页面内容完全相同的页面(被叫做kpage),共享kpage的页面的页表项都会标记为只读,而且对于原来的候选页都会有rmap_item来描述他的反向映射(其中的anon_vma成员的红黑树是描述映射这个候选页的所有vma的集合),合并的时候会加入到对应的stable tree节点和链表中。
当内存回收或页面迁移的时候,内核路径最终会调用到:
try_to_unmap //mm/rmap.c
->rmap_walk
->rmap_walk_ksm //mm/ksm.c
-> hlist_for_each_entry(rmap_item, &stable_node->hlist, hlist)
->anon_vma_interval_tree_foreach(vmac, &anon_vma->rb_root,0, ULONG_MAX)
->rwc->rmap_one
对于一个ksm页面,反向映射的时候,会拿到ksm页面对应的节点,然后遍历节点的hlist链表,拿到每一个anon_vma,然后就和上面介绍的匿名页的反向映射一样了,从anon_vma的红黑树中找到所有的vma,最后try_to_unmap_one来找到pte并解除映射关系。

下面是另一个博客总结的反向映射:
第一代反向映射:直接反向映射
在2.5版本中第一次添加反向映射功能,用来解决找到所有引用某个物理页帧的所有页表项问题,最典型的就是swap out需要修改对应的所有页表内容,在还没有修改完所有引用该页帧的页表项之前是不可以将页帧swap到硬盘上。
创建了一个新的数据结构来简化这个过程,它相当直接,为系统中每个页帧(struct page)创建一个反向映射项数组链表,包括匿名页和文件映射页,里面包含所有引用该页的页表映射项地址。

struct page {
union {
struct pte_chain chain;/ Reverse pte mapping pointer /
pte_addr_t direct; //不共享的页面直接在这保存pte地址,不需要遍历pte_chain
} pte;
}
/

  • next_and_idx encodes both the address of the next pte_chain and the
  • offset of the highest-index used pte in ptes[].
    */
    这个结构占据cache line大小,地址是cache line对齐的,所以低位可以用来标识ptes[]数组中的起始index,节省点循环时间
    struct pte_chain {
    unsigned long next_and_idx;
    pte_addr_t ptes[NRPTE];
    } ____cacheline_aligned;

struct page {
union {
struct pte_chain chain;/ Reverse pte mapping pointer /
pte_addr_t direct; //不共享的页面直接在这保存pte地址,不需要遍历pte_chain
} pte;
}
/

  • next_and_idx encodes both the address of the next pte_chain and the
  • offset of the highest-index used pte in ptes[].
    */
    这个结构占据cache line大小,地址是cache line对齐的,所以低位可以用来标识ptes[]数组中的起始index,节省点循环时间
    struct pte_chain {
    unsigned long next_and_idx;
    pte_addr_t ptes[NRPTE];
    } ____cacheline_aligned;

不共享的页面可以使用direct直接指向pte entry就OK了。页面共享时,chain则会指向一个struct pte_chain的链表,只需要遍历pte_chain解除映射就OK了。
不过在整个过程中还做了一项不起眼但是非常重要的工作,刷TLB。当页表改变之后如果正好是当前进程正在使用这个页表,这时候需要flush TLB,无效这个页表项,但是清空所有的TLB代价比较大,所以它检查了一下修改的页表项是否是当前正在使用的。
下图展示了从pte到对应的vma的过程:
在这里插入图片描述1、pte_chain中存储的引用该页的pte地址
2、根据pte地址进行页对齐,找到对应的页帧号和page
3、page的mapping指向对应进程mm_struct,index代表了虚拟地址(页对齐)的偏移,寻找vma
4、比较当前进程的mm_struct和正在修改的mm_struct,如果是同一个则需要flush对应的tlb项,如果不是就什么都不要做
文件页的反向映射
第一代直接反向映射的方式很容易理解,但是它引入了一些其他的问题,反向映射项占据了太多的内存,并且需要额外的消耗来维护这些关系。那些需要大量页申请、释放、拷贝的操作会被减慢,在fork()系统调用的时候,必须为进程地址空间中每个页帧添加反向映射项链表,这个速度太慢了。
Dave McCracken提出了一个新的patch来解决这个问题,称之为"object-based reverse mapping"(基于对象的反向映射),这里的对象是“文件”,解决了文件映射页的反向映射关系查找。
用户空间使用的页有两种基本类型:匿名页和文件页,其中文件映射页和文件系统中某个文件相关联,一般包括进程的代码段、通过mmap映射的文件页,这部分页可以不需要通过直接反向映射表项就能找到所有的页表项。
在这里插入图片描述
每个页帧对应的struct page结构,它有一个成员mapping,当页是通过文件映射的时候,它指向address_space,包含有文件对应的inode信息和其他的管理数据信息,其中的两个双向链表 (i_mmap和i_mmap_shared) 包含了映射该文件的所有的vm_area_struct,后者是共享映射方式的页,例如mmap(MAP_SHARED)操作建立的页。vm_area_struct描述了一个区间的进程地址空间的信息,通过/proc/pid/maps可以看到进程的所有的VMA。而通过VMA可以找到特定的页映射到进程的地址空间,这样就可以找到对应的页表项。
在这里插入图片描述1、文件映射页的page->mapping指向address_space,其中的两个链表i_mmap和i_mmap_shared挂有映射该页的vma对象
2、page->index代表在文件中的偏移,而vma->vm_pgoff是映射文件时的offset,即这块vma区间的起始地址对应的文件偏移,通过mmap(…int fd, off_t offset)指定了文件的偏移,根据page->index计算出物理页在这个进程中的虚拟地址
3、根据虚拟地址address找到对应的pte项
inline unsigned long
vma_address(struct page *page, struct vm_area_struct *vma)
{
pgoff_t pgoff = page->index << (PAGE_CACHE_SHIFT - PAGE_SHIFT);
unsigned long address;
address = vma->vm_start + ((pgoff - vma->vm_pgoff) << PAGE_SHIFT);
return address;
}
文件页的反向映射就通过这条路径来处理了,这条路径比直接的页表项指针要长,但是它肯定内存消耗会小很多,不需要维护额外的pte_chain信息。但是匿名页没有对应的address_space结构,所以object-based(基于文件对象)的反向映射处理不了匿名页,所以这段事件是两种方式共存:直接反向映射来处理匿名页,基于对象的反向映射处理文件页
匿名页的反向映射
后来Andrea Arcangeli提交了另外的patch来处理匿名页的反向映射问题,原来因为匿名页没有文件对象,所以复用不了address_space的那套东西嘛,所以他发明了一个新的对象来代替address_space的作用,通过复用struct page->mapping来指向一个anon_vma,它上面挂了所有可能共享该区域的VMA。
一个进程通过malloc来申请内存,随后分配物理页,也就是匿名页,在创建第一个区域的时候从来都不会是共享的,所以对于一个新的匿名页不需要反向映射的链,此时它是进程私有的。Andrea的patch通过在struct page中添加一个union来共用mapping指针,称之为vma,指向一个单独的VMA结构,如果一个进程中在同一个VMA中有几个私有的匿名页,他们的关系就像这样(我没见过这个版本的代码哈):
在这里插入图片描述通过这个结构,内核可以通过vma结构体找到给定页帧对应的页表。
当进程开始fork之后,事情会变得复杂,一旦fork之后,父子进程都有页表指向相同的匿名页,单个的VMA指针不再能满足需求。所以Andread创建了一个新的anon_vma结构来管理VMA的链表关系。struct page的union的第三个对象就指向了anon_vma,里面主要是一个双链表,所有可能包含该页帧的vma都在上面,现在看起来是下面这样:
在这里插入图片描述如果内核需要unmap这样的页帧,它需要遍历anon_vma的链表,测试它找到的每一个vma。一旦所有的页表都已经unmap之后,页帧就可以释放了。
这种方案也需要一些额外的内存消耗:VMA结构需要增加一个新的list_head对象,当页开始共享的时候需要分配一个新的anon_vma结构。一个VMA可以跨越几千个页帧,所以相对于每个page中的反向映射,VMA中的消耗也不算什么了。
在这里插入图片描述这种方式和文件页的反向映射非常相似,page->index表示的虚拟地址(页对齐),vma->vm_pgoff一般是0,不同的只有address_space换成了anon_vma,怎么查找到页表项的过程就不再重复了。
这种方法会增加很多的计算量。释放一个页帧需要扫描多个VMA,这些VMA可能包含也可能不包含对页帧的引用。这些计算量会随着一块内存区域被更多进程共享而增加,这些问题在还没有合并的时候就已经提出了,但是并不是如何严重,所以everything is OK。
在这里插入图片描述1、初始状态,进程1通过malloc申请了一片区域,此时有VMA和一个anon_vma,世界如此美好哈
2、进程开始fork出了很多进程,此时COW机制的存在,所有进程都有自己的vma但是共享同一片物理内存,在父进程的anon_vma链接有所有子进程的vma。这里他假设的是anon_vma和address_space是等同的,同一个文件的映射页归根结底都需要访问同一个inode和address_space,它这里将父进程初始的vma区域看做一个文件,子进程相同的vma映射的也是这个文件,即vma对应的物理页是相同的。很可惜不是,在进行写操作的时候就开始分裂了,大家只是虚拟地址区间看起来是一样的,其实已经分道扬镳了。
3、对于vma可以进行扩张、分裂,图3中是经过一系列vma的变形之后,现在父进程和子进程的vma和初始的vma已经没有交集了,但是他们还挂在父进程的anon_vma中。swap out父进程或子进程vma对应的物理页时仍然会遍历两个进程的vma,这完全是冗余的。

2 进程管理

2.1进程调度

进程调度含义
进程调度决定了将哪个进程进行执行,以及执行的时间。操作系统进行合理的进程调度,使得资源得到最大化的利用。
在单片机上,常常使用的方式是:系统初始化---->while(1){}。(当然,单片机也可以跑类似 FreeRTOS,也可以有进程切换)
在带操作系统的 CPU 上跑的逻辑是,允许多个进程(其实就是程序) ”同时” 跑。比如,你可以在操作鼠标的同时,进行音乐播放,文字编辑等。宏观上看上去是多个任务并行执行,事实的本质是 CPU 在不断的调度每一个进程,使得每个进程都得以响应,与此同时,还要兼顾不同场景下的响应效率(进程的执行时间)。
进程调度器的任务就是合理分配CPU时间给运行的进程,创造一种所有进程并行运行的错觉。这就对调度器提出了要求:
1、调度器分配的CPU时间不能太长,否则会导致其他的程序响应延迟,难以保证公平性。
2、调度器分配的时间也不能太短,每次调度会导致上下文切换,这种切换开销很大。
而调度器的任务就是:1、分配时间给进程 2、上下文切换
所以具体而言,调度器的任务就明确了:用一句话表述就是在恰当的实际,按照合理的调度算法,选择进程,让进程运行到它应该运行的时间,切换两个进程的上下文。

I/O 消耗型和 CPU 消耗型
运行的进程如果大部分来进行 I/O 的请求或者等待的话,这个进程称之为 I/O 消耗型,比如键盘。这种类型的进程经常处于可以运行的状态,但是都只是运行一点点时间,绝大多数的时间都在处于阻塞(睡眠)的状态。
如果进程的绝大多数都在使用 CPU 做运算的话,那么这种进程称之为 CPU 消耗型,比如开启 Matlab 做一个大型的运算。没有太多的 I/O 需求,从系统响应的角度上来讲,调度器不应该经常让他们运行。对于处理器消耗型的进程,调度策略往往是降低他们的执行频率,延长运行时间。
Linux 系统为了提升响应的速度,倾向于优先调度 I/O 消耗型

进程的优先级
调度算法中比较基本的就是靠进程的优先级来进行进程的调度,比如 FreeRTOS,靠 task 的优先级来进行进程的抢占。
一、普通进程
在 Linux 中普通进程依赖称之为 nice 值 的东东来进行进程的优先级描述。nice 值的范围是 [-20, 19]。默认的 default 值为 0;越低的 nice 值,代表着越高的优先级,反之,越高的 nice 值代表着越低的优先级。
越高优先级的 普通进程 有着越高的执行时间(注意,这里值的越高的执行时间,指的是在一小段观察时间内,每个可执行的进程都执行一遍的情况,这里的描述可能产生一些歧义,稍安勿躁,接着看)。可以通过 ps -el 查看系统中进程列表
二、实时进程
实时优先级是可配置的默认情况下的范围是 0~99,与 nice 值相反,越高的实时优先级数值代表着越高的优先级。与此同时,任何实时进程的优先级都高于普通进程的优先级。
—— 小结
实时进程优先级:value 越高,优先级越大
普通进程优先级:nice值越高,普通进程的优先级越小
任何实时进程的优先级 > 普通进程

Linux 调度算法
Linux 中有一个总的调度结构,称之为 调度器类(scheduler class),它允许不同的可动态添加的调度算法并存,总调度器根据调度器类的优先顺序,依次去进行调度器类的中的进程进行调度,挑选了调度器类,再在这个调度器内,使用这个调度器类的算法(调度策略)进行内部的调度。
在这里插入图片描述调度器的优先级顺序为:
Scheduling Class 的优先级顺序为 Stop_ask > Real_Time > Fair > Idle_Task,开发者可以根据己的设计需求,來把所属的Task配置到不同的Scheduling Class中。其中的 Real_time 和 Fair 是最最常用的,下面主要聊聊着两类。
一、Fair 调度使用的是 CFS 的调度算法,即完全公平调度器
对于一个普通进程,CFS 调度器调度它执行(SCHED_NORMAL),需要考虑两个方面维度:

  1. 如何挑选哪一个进程进入运行状态?
    —— 在 CFS 中,给每一个进程安排了一个虚拟时钟 vruntime(virtual runtime),这个变量并非直接等于他的绝对运行时间,而是根据运行时间放大或者缩小一个比例,CFS 使用这个 vruntime 来代表一个进程的运行时间。如果一个进程得以执行,那么他的 vruntime 将不断增大,直到它没有执行。没有执行的进程的 vruntime 不变。调度器为了体现绝对的完全公平的调度原则,总是选择 vruntime 最小的进程,让其投入执行。他们被维护到一个以 vruntime 为顺序的红黑树 rbtree 中,每次去取最小的 vruntime 的进程来投入运行。实际运行时间到 vruntime 的计算公式为:
    [ vruntime = 实际运行时间 * 1024 / 进程权重 ]
    这里的1024代表nice值为0的进程权重。所有的进程都以nice为0的权重1024作为基准,计算自己的vruntime。上面两个公式可得出,虽然进程的权重不同,但是它们的 vruntime增长速度应该是一样的 ,与权重无关。既然所有进程的vruntime增长速度宏观上看应该是同时推进的,那么就可以用vruntime来选择运行的进程,vruntime值较小就说明它以前占用cpu的时间较短,受到了“不公平”对待,因此下一个运行进程就是它。这样既能公平选择进程,又能保证高优先级进程获得较多的运行时间,这就是CFS的主要思想。
  2. 挑选的进程进行运行了,它运行多久?
    进程运行的时间是根据进程的权重进行分配。
    [ 分配给进程的运行时间 = 调度周期 *(进程权重 / 所有进程权重之和) ]
    CFS 调度器实体结构作为一个名为 se 的 sched_entity 结构,嵌入到进程描述符 struct task_struct 中

struct sched_entity {
struct load_weight load; /* for load-balancing */
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;

u64			exec_start;
u64			sum_exec_runtime;
u64			vruntime;
u64			prev_sum_exec_runtime;

u64			nr_migrations;

#ifdef CONFIG_SCHEDSTATS
struct sched_statistics statistics;
#endif

#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity parent;
/
rq on which this entity is (to be) queued: */
struct cfs_rq cfs_rq;
/
rq “owned” by this entity/group: */
struct cfs_rq *my_q;
#endif
};

二、实时调度策略
对于实时调度策略分为两种:SCHED_FIFO 和 SCHED_RR:
这两种进程都比任何普通进程的优先级更高(SCHED_NORMAL),都会比他们更先得到调度。
SCHED_FIFO : 一个这种类型的进程出于可执行的状态,就会一直执行,直到它自己被阻塞或者主动放弃 CPU;它不基于时间片,可以一直执行下去,只有更高优先级的 SCHED_FIFO 或者 SCHED_RR 才能抢占它的任务,如果有两个同样优先级的 SCHED_FIFO 任务,它们会轮流执行,其他低优先级的只有等它们变为不可执行状态,才有机会执行。
SCHED_RR : 与 SCHED_FIFO 大致相同,只是 SCHED_RR 级的进程在耗尽其时间后,不能再执行,需要接受 CPU 的调度。当 SCHED_RR 耗尽时间后,同一优先级的其他实时进程被轮流调度。
上述两种实时算法都是静态的优先级。内核不为实时优先级的进程计算动态优先级,保证给定的优先级的实时进程总能够抢占比他优先级低的进程。
Linux 调度时机
一、进程切换
从进程的角度看,CPU是共享资源,由所有的进程按特定的策略轮番使用。一个进程离开CPU、另一个进程占据CPU的过程,称为进程切换(process switch)。进程切换是在内核中通过调用schedule()完成的。
发生进程切换的场景有以下三种:
1、进程运行不下去了:
比如因为要等待IO完成,或者等待某个资源、某个事件,典型的内核代码如下:

//把进程放进等待队列,把进程状态置为TASK_UNINTERRUPTIBLE
prepare_to_wait(waitq, wait, TASK_UNINTERRUPTIBLE);
//切换进程
schedule();

2、进程还在运行,但内核不让它继续使用CPU了:
比如进程的时间片用完了,或者优先级更高的进程来了,所以该进程必须把CPU的使用权交出来;
3、进程还可以运行,但它自己的算法决定主动交出CPU给别的进程:
用户程序可以通过系统调用sched_yield()来交出CPU,内核则可以通过函数cond_resched()或者yield()来做到。
进程切换分为自愿切换(Voluntary)和强制切换(Involuntary),以上场景1属于自愿切换,场景2和3属于强制切换。
自愿切换发生的时候,进程不再处于运行状态,比如由于等待IO而阻塞(TASK_UNINTERRUPTIBLE),或者因等待资源和特定事件而休眠(TASK_INTERRUPTIBLE),又或者被debug/trace设置为TASK_STOPPED/TASK_TRACED状态;
强制切换发生的时候,进程仍然处于运行状态(TASK_RUNNING),通常是由于被优先级更高的进程抢占(preempt),或者进程的时间片用完了。
注意:进程可以通过调用sched_yield()主动交出CPU,这不是自愿切换,而是属于强制切换,因为进程仍然处于运行状态。有时候内核代码会在耗时较长的循环体内通过调用 cond_resched()或yield() ,主动让出CPU,以免CPU被内核代码占据太久,给其它进程运行机会。这也属于强制切换,因为进程仍然处于运行状态。
进程自愿切换(Voluntary)和强制切换(Involuntary)的次数被统计在 /proc//status 中,其中voluntary_ctxt_switches表示自愿切换的次数,nonvoluntary_ctxt_switches表示强制切换的次数,两者都是自进程启动以来的累计值。
也可以用 pidstat -w 命令查看进程切换的每秒统计值:

pidstat -w 1
Linux 3.10.0-229.14.1.el7.x86_64 (bj71s060) 02/01/2018 x86_64 (2 CPU)

12:05:20 PM UID PID cswch/s nvcswch/s Command
12:05:21 PM 0 1299 0.94 0.00 httpd
12:05:21 PM 0 27687 0.94 0.00 pidstat

自愿切换和强制切换的统计值在实践中有什么意义呢?
大致而言,如果一个进程的自愿切换占多数,意味着它对CPU资源的需求不高。如果一个进程的强制切换占多数,意味着对它来说CPU资源可能是个瓶颈,这里需要排除进程频繁调用sched_yield()导致强制切换的情况。
二、调度时机
自愿切换意味着进程需要等待某种资源,强制切换则与抢占(Preemption)有关。
抢占(Preemption)是指内核强行切换正在CPU上运行的进程,在抢占的过程中并不需要得到进程的配合,在随后的某个时刻被抢占的进程还可以恢复运行。发生抢占的原因主要有:进程的时间片用完了,或者优先级更高的进程来争夺CPU了。
抢占的过程分两步,第一步触发抢占,第二步执行抢占,这两步中间不一定是连续的,有些特殊情况下甚至会间隔相当长的时间:

触发抢占:给正在CPU上运行的当前进程设置一个请求重新调度的标志(TIF_NEED_RESCHED),仅此而已,此时进程并没有切换。
执行抢占:在随后的某个时刻,内核会检查TIF_NEED_RESCHED标志并调用schedule()执行抢占。

抢占只在某些特定的时机发生,这是内核的代码决定的。
触发抢占的时机
每个进程都包含一个TIF_NEED_RESCHED标志,内核根据这个标志判断该进程是否应该被抢占,设置TIF_NEED_RESCHED标志就意味着触发抢占。
直接设置TIF_NEED_RESCHED标志的函数是 set_tsk_need_resched();
触发抢占的函数是resched_task()。
TIF_NEED_RESCHED标志什么时候被设置呢?在以下时刻:
1、周期性的时钟中断
时钟中断处理函数会调用scheduler_tick(),这是调度器核心层(scheduler core)的函数,它通过调度类(scheduling class)的task_tick方法 检查进程的时间片是否耗尽,如果耗尽则触发抢占:
/*

  • This function gets called by the timer code, with HZ frequency.
  • We call it with interrupts disabled.
    */
    void scheduler_tick(void)
    {

    curr->sched_class->task_tick(rq, curr, 0);

    }

2、唤醒进程的时候
当进程被唤醒的时候,如果优先级高于CPU上的当前进程,就会触发抢占。相应的内核代码中,try_to_wake_up()最终通过check_preempt_curr()检查是否触发抢占。
3、新进程创建的时候
如果新进程的优先级高于CPU上的当前进程,会触发抢占。相应的调度器核心层代码是sched_fork(),它再通过调度类的 task_fork方法触发抢占:
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{

if (p->sched_class->task_fork)
p->sched_class->task_fork§;

}
4、进程修改nice值的时候
如果进程修改nice值导致优先级高于CPU上的当前进程,也会触发抢占。内核代码参见 set_user_nice()。
5、进行负载均衡的时候
在多CPU的系统上,进程调度器尽量使各个CPU之间的负载保持均衡,而负载均衡操作可能会需要触发抢占。
不同的调度类有不同的负载均衡算法,涉及的核心代码也不一样,比如CFS类在load_balance()中触发抢占:
load_balance()
{

move_tasks();

resched_cpu();

}
RT类的负载均衡基于overload,如果当前运行队列中的RT进程超过一个,就调用push_rt_task()把进程推给别的CPU,在这里会触发抢占。
执行抢占的时机
触发抢占通过设置进程的TIF_NEED_RESCHED标志告诉调度器需要进行抢占操作了,但是真正执行抢占还要等内核代码发现这个标志才行,而内核代码只在设定的几个点上检查TIF_NEED_RESCHED标志,这也就是执行抢占的时机。
抢占如果发生在进程处于用户态的时候,称为User Preemption(用户态抢占);如果发生在进程处于内核态的时候,则称为Kernel Preemption(内核态抢占)。
执行User Preemption(用户态抢占)的时机

  1. 从系统调用(syscall)返回用户态时;
    源文件:arch/x86/kernel/entry_64.S
    sysret_careful:
    bt $TIF_NEED_RESCHED,%edx
    jnc sysret_signal
    TRACE_IRQS_ON
    ENABLE_INTERRUPTS(CLBR_NONE)
    pushq_cfi %rdi
    call schedule
    popq_cfi %rdi
    jmp sysret_check

  2. 从中断返回用户态时:

    retint_careful:
    CFI_RESTORE_STATE
    bt $TIF_NEED_RESCHED,%edx
    jnc retint_signal
    TRACE_IRQS_ON
    ENABLE_INTERRUPTS(CLBR_NONE)
    pushq_cfi %rdi
    call schedule
    popq_cfi %rdi
    GET_THREAD_INFO(%rcx)
    DISABLE_INTERRUPTS(CLBR_NONE)
    TRACE_IRQS_OFF
    jmp retint_check

执行Kernel Preemption(内核态抢占)的时机
inux在2.6版本之后就支持内核抢占了,但是请注意,具体取决于内核编译时的选项:
CONFIG_PREEMPT_NONE=y
不允许内核抢占。这是SLES的默认选项。
CONFIG_PREEMPT_VOLUNTARY=y
在一些耗时较长的内核代码中主动调用cond_resched()让出CPU。这是RHEL的默认选项。
CONFIG_PREEMPT=y
允许完全内核抢占。

在 CONFIG_PREEMPT=y 的前提下,内核态抢占的时机是:

  1. 中断处理程序返回内核空间之前会检查TIF_NEED_RESCHED标志,如果置位则调用preempt_schedule_irq()执行抢占。preempt_schedule_irq()是对schedule()的包装。

    #ifdef CONFIG_PREEMPT

         /* Returning to kernel space. Check if we need preemption */
    
         /* rcx:  threadinfo. interrupts off. */
    

    ENTRY(retint_kernel)

         cmpl $0,TI_preempt_count(%rcx)
    
         jnz  retint_restore_args
    
         bt  $TIF_NEED_RESCHED,TI_flags(%rcx)
    
         jnc  retint_restore_args
    
         bt   $9,EFLAGS-ARGOFFSET(%rsp)  /* interrupts off? */
    
         jnc  retint_restore_args
    
         call preempt_schedule_irq
    
         jmp exit_intr
    

    #endif

  2. 当内核从non-preemptible(禁止抢占)状态变成preemptible(允许抢占)的时候;
    在preempt_enable()中,会最终调用 preempt_schedule 来执行抢占。preempt_schedule()是对schedule()的包装。

3 中断管理

3.1中断机制

1.中断概念
中断是指在CPU正常运行期间,由于内外部事件或由程序预先安排的事件引起的CPU暂时停止正在运行的程序,转而为该内部或外部事件或预先安排的事件服务的程序中去,服务完毕后再返回去继续运行被暂时中断的程序。Linux中通常分为外部中断(又叫硬件中断)和内部中断(又叫异常)。
在实地址模式中,CPU把内存中从0开始的1KB空间作为一个中断向量表。表中的每一项占4个字节。但是在保护模式中,有这4个字节的表项构成的中断向量表不满足实际需求,于是根据反映模式切换的信息和偏移量的足够使得中断向量表的表项由8个字节组成,而中断向量表也叫做了中断描述符表(IDT)。在CPU中增加了一个用来描述中断描述符表寄存器(IDTR),用来保存中断描述符表的起始地址。
2. Linux中断处理
  2.1 系统中断号
  由上述中断定义可知,系统中断向量表中共可保存256个中断向量入口,即IDT中包含的256个中断描述符(对应256个中断向量)。
  而0-31号中断向量被intel公司保留用来处理异常事件,不能另作它用。对这 0-31号中断向量,操作系统只需提供异常的处理程序,当产生一个异常时,处理机就会自动把控制转移到相应的处理程序的入口,运行相应的处理程序;而事实 上,对于这32个处理异常的中断向量,2.6版本的 Linux只提供了0-17号中断向量的处理程序,其对应处理程序参见下表、中断向量和异常事件对应表;也就是说,17-31号中断向量是空着未用的。

中断向量号 异常事件 Linux的处理程序
0 除法错误 Divide_error
1 调试异常 Debug
2 NMI中断 Nmi
3 单字节,int 3 Int3
4 溢出 Overflow
5 边界监测中断 Bounds
6 无效操作码 Invalid_op
7 设备不可用 Device_not_available
8 双重故障 Double_fault
9 协处理器段溢出 Coprocessor_segment_overrun
10 无效TSS Incalid_tss
11 缺段中断 Segment_not_present
12 堆栈异常 Stack_segment
13 一般保护异常 General_protection
14 页异常 Page_fault
15 (intel保留) Spurious_interrupt_bug
16 协处理器出错 Coprocessor_error
17 对齐检查中断 Alignment_check

0-31号中断向量已被保留,那么剩下32-255共224个中断向量可用。 这224个中断向量又是怎么分配的呢?2.6版本的Linux中,除了0x80 (SYSCALL_VECTOR)用作系统调用总入口之外,其他都用在外部硬件中断源上,其中包括可编程中断控制器8259A的15个irq;事实上,当 没有定义CONFIG_X86_IO_APIC时,其他223(除0x80外)个中断向量,只利用了从32号开始的15个,其它208个空着未用。
 2.2 中断请求
  2.2.1 中断请求概述
  外部设备当需要操作系统做相关的事情的时候,会产生相应的中断。
  设备通过相应的中断线向中断控制器发送高电平以产生中断信号,而操作系统则会从中断控制器的状态位取得那根中断线上产生的中断。而且只有在设备在对某一条中断线拥有控制权,才可以向这条中断线上发送信号。也由于现在的外设越来越多,中断线又是很宝贵的资源不可能被一一对应。因此在使用中断线前,就得对相应的中断线进行申请。无论采用共享中断方式还是独占一个中断,申请过程都是先讲所有的中断线进行扫描,得出哪些没有别占用,从其中选择一个作为该设备的IRQ。其次,通过中断申请函数申请相应的IRQ。最后,根据申请结果查看中断是否能够被执行。
2.2.3 中断请求实现
上下半部机制
  我们期望让中断处理程序运行得快,并想让它完成的工作量多,这两个目标相互制约,如何解决——上下半部机制。
  我们把中断处理切为两半。中断处理程序是上半部——接受中断,他就立即开始执行,但只有做严格时限的工作。能够被允许稍后完成的工作会推迟到下半部去,此后,在合适的时机,下半部会被开终端执行。上半部简单快速,执行时禁止一些或者全部中断。
  下半部稍后执行,而且执行期间可以响应所有的中断。这种设计可以使系统处于中断屏蔽状态的时间尽可能的短,以此来提高系统的响应能力。上半部只有中断处理程序机制,而下半部的实现有软中断实现,tasklet实现和工作队列实现。
  我们用网卡来解释一下这两半。当网卡接受到数据包时,通知内核,触发中断,所谓的上半部就是,及时读取数据包到内存,防止因为延迟导致丢失,这是很急迫的工作。读到内存后,对这些数据的处理不再紧迫,此时内核可以去执行中断前运行的程序,而对网络数据包的处理则交给下半部处理。
上下半部划分原则
  1) 如果一个任务对时间非常敏感,将其放在中断处理程序中执行;
  2) 如果一个任务和硬件有关,将其放在中断处理程序中执行;
  3) 如果一个任务要保证不被其他中断打断,将其放在中断处理程序中执行;
  4) 其他所有任务,考虑放置在下半部执行。
下半部实现机制之软中断
  软中断作为下半部机制的代表,是随着SMP(share memory processor)的出现应运而生的,它也是tasklet实现的基础(tasklet实际上只是在软中断的基础上添加了一定的机制)。软中断一般是“可延迟函数”的总称,有时候也包括了tasklet(请读者在遇到的时候根据上下文推断是否包含tasklet)。它的出现就是因为要满足上面所提出的上半部和下半部的区别,使得对时间不敏感的任务延后执行,软中断执行中断处理程序留给它去完成的剩余任务,而且可以在多个CPU上并行执行,使得总的系统效率可以更高。它的特性包括:
  a)产生后并不是马上可以执行,必须要等待内核的调度才能执行。软中断不能被自己打断,只能被硬件中断打断(上半部)。
  b)可以并发运行在多个CPU上(即使同一类型的也可以)。所以软中断必须设计为可重入的函数(允许多个CPU同时操作),因此也需要使用自旋锁来保护其数据结构。
下半部实现机制之tasklet
  tasklet是通过软中断实现的,所以它本身也是软中断。
  软中断用轮询的方式处理。假如正好是最后一种中断,则必须循环完所有的中断类型,才能最终执行对应的处理函数。显然当年开发人员为了保证轮询的效率,于是限制中断个数为32个。
  为了提高中断处理数量,顺道改进处理效率,于是产生了tasklet机制。
  Tasklet采用无差别的队列机制,有中断时才执行,免去了循环查表之苦。Tasklet作为一种新机制,显然可以承担更多的优点。正好这时候SMP越来越火了,因此又在tasklet中加入了SMP机制,保证同种中断只能在一个cpu上执行。在软中断时代,显然没有这种考虑。因此同一种软中断可以在两个cpu上同时执行,很可能造成冲突。
  
  总结下tasklet的优点:
  (1)无类型数量限制;
  (2)效率高,无需循环查表;
  (3)支持SMP机制;
  它的特性如下:
  1)一种特定类型的tasklet只能运行在一个CPU上,不能并行,只能串行执行。
  2)多个不同类型的tasklet可以并行在多个CPU上。
  3)软中断是静态分配的,在内核编译好之后,就不能改变。但tasklet就灵活许多,可以在运行时改变(比如添加模块时)。
下半部实现机制之工作队列(work queue)
  上面我们介绍的可延迟函数运行在中断上下文中(软中断的一个检查点就是do_IRQ退出的时候),于是导致了一些问题:软中断不能睡眠、不能阻塞。由于中断上下文出于内核态,没有进程切换,所以如果软中断一旦睡眠或者阻塞,将无法退出这种状态,导致内核会整个僵死。但可阻塞函数不能用在中断上下文中实现,必须要运行在进程上下文中,例如访问磁盘数据块的函数。因此,可阻塞函数不能用软中断来实现。但是它们往往又具有可延迟的特性。

上面我们介绍的可延迟函数运行在中断上下文中,于是导致了一些问题,说明它们不可挂起,也就是说软中断不能睡眠、不能阻塞,原因是由于中断上下文出于内核态,没有进程切换,所以如果软中断一旦睡眠或者阻塞,将无法退出这种状态,导致内核会整个僵死。因此,可阻塞函数不能用软中断来实现。但是它们往往又具有可延迟的特性。而且由于是串行执行,因此只要有一个处理时间较长,则会导致其他中断响应的延迟。为了完成这些不可能完成的任务,于是出现了工作队列,它能够在不同的进程间切换,以完成不同的工作。

如果推后执行的任务需要睡眠,那么就选择工作队列,如果不需要睡眠,那么就选择软中断或tasklet。工作队列能运行在进程上下文,它将工作托付给一个内核线程。工作队列说白了就是一组内核线程,作为中断守护线程来使用。多个中断可以放在一个线程中,也可以每个中断分配一个线程。我们用结构体workqueue_struct表示工作者线程,工作者线程是用内核线程实现的。而工作者线程是如何执行被推后的工作——有这样一个链表,它由结构体work_struct组成,而这个work_struct则描述了一个工作,一旦这个工作被执行完,相应的work_struct对象就从链表上移去,当链表上不再有对象时,工作者线程就会继续休眠。因为工作队列是线程,所以我们可以使用所有可以在线程中使用的方法。
Linux软中断和工作队列的作用是什么
  Linux中的软中断和工作队列是中断上下部机制中的下半部实现机制。
  1.软中断一般是“可延迟函数”的总称,它不能睡眠,不能阻塞,它处于中断上下文,不能进城切换,软中断不能被自己打断,只能被硬件中断打断(上半部),可以并发的运行在多个CPU上。所以软中断必须设计成可重入的函数,因此也需要自旋锁来保护其数据结构。
  2.工作队列中的函数处在进程上下文中,它可以睡眠,也能被阻塞,能够在不同的进程间切换,以完成不同的工作。
可延迟函数和工作队列都不能访问用户的进程空间,可延时函数在执行时不可能有任何正在运行的进程,工作队列的函数有内核进程执行,他不能访问用户空间地址。
可参考以下博客:
http://www.wowotech.net/irq_subsystem/interrupt_subsystem_architecture.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值