Linux内存管理中锁使用分析及典型优化案例总结

1技术背景   

锁在Linux内存管理中起着非常重要的作用。一方面,锁在内存管理中保护了多线程的临界区并发处理; 另一方面,内存管理各种锁的使用在一些场景也会表现出性能问题。本文针对内核内存管理中典型的锁进行介绍及典型优化案例总结。

本文分析基于Linux内核6.9版本(部分为低版本内核,会特别说明)。

2内存管理中的锁  

2.1PG_Locked  

Linux内核物理内存使用page进行管理,page的使用需要考虑并发处理,在内核中借助PG_locked实现,当page被标记了PG_locked时表明page已经被锁定,正在使用中,不要修改。page结构体中定义了flag可以表示是否被锁定。

struct page {

       unsigned long flags;              /* Atomic flags, some possibly        

                                    * updated asynchronously */

}

内核中使用lock_page来对page上锁。接口如下:

static inline bool folio_trylock(struct folio *folio)

{

       return likely(!test_and_set_bit_lock(PG_locked, folio_flags(folio, 0)));

}

void __folio_lock(struct folio *folio)

{

       folio_wait_bit_common(folio, PG_locked, TASK_UNINTERRUPTIBLE,

                            EXCLUSIVE);

}

static inline void lock_page(struct page *page)

{        

       struct folio *folio;

       might_sleep();

       folio = page_folio(page);

       if (!folio_trylock(folio))

              __folio_lock(folio);

}

从上面代码可以看到,lock_page是可能存在睡眠的,因此,不要在不可睡眠的上下文使用。它会尝试对page设置PG_locked标记,如果page的PG_locked已经被置位,也就是此时有人正在访问此page,会通过__folio_lock设置uninterruptable sleep状态等待PG_locked标记被清除。

以典型的filemap_fault为例,使用PG_locked流程如下:

3d03102eea4dbc975418fa1384427d25.png

filemap_fault à __filemap_get_folio àfilemap_get_pages à filemap_add_folioà __folio_set_locked(设置page的PG_locked)    

filemap_read_folioà folio_wait_locked_killableà folio_wait_bit_killable(folio, PG_locked) –> folio_wait_bit_common(等IO完成PG_locked被清除)

mpage_read_end_ioàfolio_mark_uptodateàfolio_unlock(IO完成时会标记page的PG_update,同时清除PG_locked)。

简单来说,当发生文件页pagefault,所需内存不在文件缓存时,会分配page页面,发起IO读操作,但这里IO读操作仅下发IO读请求,还不能保证page中已经读取到所需内容,因此会在下发IO读请求前设置了PG_locked标记,当IO完成时,会清除PG_locked标记。这也就是为什么可以在systrace中看到blockio黄条时一般会blocked reson为folio_wait_bit_killable的原因。

同理,匿名页发生page fault时也是类似流程,只是如果使用zram时,没有实际的IO而已。整个过程同样也是由PG_locked控制页面等待及完成读取(解压缩)的并发过程。

2.2lru_lock  

Linux内核在内存回收是使用LRU(Last Recent Used)算法,即最近最少使用算法。在内存回收时扫描active和inactive LRU链表进行。lruvec结构体中有一个自旋锁保护LRU链表的操作过程的并发问题。    

struct lruvec {

       struct list_head              lists[NR_LRU_LISTS];

       /* per lruvec lru_lock for memcg */

       spinlock_t                     lru_lock;

}

shrink_inactive_list和shrink_active_list等典型的操作LRU的过程都需要持有此锁。下面以shrink_inactive_list为例,列举了锁的使用过程。

8f6b967743fdbc6cfa9977ce00f6af57.png

2.3mmap_lock  

Linux内核用vma表示进程地址空间,进程地址的访问受mmap_lock锁保护。mmap_lock是定义在mm_struct中的读写信号量成员。

struct mm_struct {

       …

              struct rw_semaphore mmap_lock;

}

mmap_lock保护进程虚拟地址vma rbtree、vma list、vma flags等。进程发生page fault缺页,mmap, mprotect等访问vma的操作时,可能会持有该锁。

获取mmap_lock写锁一般以下典型接口:

mmap_write_trylock

mmap_write_lock_killable

mmap_write_lock

mmap_write_lock_nested

我们以mmap_write_lock_killable为例看下具体的API实现:

#define TASK_KILLABLE                     (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)        

static inline int __down_write_killable(struct rw_semaphore *sem)

{

       return __down_write_common(sem, TASK_KILLABLE);

}

int __sched down_write_killable(struct rw_semaphore *sem)

{

       might_sleep();

       rwsem_acquire(&sem->dep_map, 0, 0, _RET_IP_);

       if (LOCK_CONTENDED_RETURN(sem, __down_write_trylock,

                              __down_write_killable)) {

              rwsem_release(&sem->dep_map, _RET_IP_);

              return -EINTR;

       }        

       return 0;

}

static inline int mmap_write_lock_killable(struct mm_struct *mm)

{

       int ret;

       __mmap_lock_trace_start_locking(mm, true);

       ret = down_write_killable(&mm->mmap_lock);

       __mmap_lock_trace_acquire_returned(mm, true, ret == 0);

       return ret;

}

从上面可以看出down_write_killable获取mmap_lock过程可能会sleep,因此调用者需要注意不能使用在不可睡眠上下文。如果成功获取到锁,返回0,如果获取不到锁(锁已经被其它线程持有),会设置当前进程为TASK_KILLABLE状态,TASK_KILLABLE状态其实就是可杀的D状态,从宏定义可以到它是TASK_WAKEKILL | TASK_UNINTERRUPTIBLE的组合。    

在systrace中经常可以看到这几个函数的block reson的紫色的uninterruptable sleep的D状态。

mprotect可以用来修改一段指定内存区域的保护属性。由于它会修改进程vma区域flag,因此,为了处理并发问题,需要mmap_lock的保护。我们以mprotect为例,分析mmap_lock的使用。

2fe70ab1cde61df70b76f3befb779e7e.png

内核中的 mmap_lock(在5.15之前的内核版本中称为 mmap_sem)锁,实际上是一个读写锁,可以支持并发的多线程读访问。然而,在某些情况下,即使需要获取读取者(reader)读锁,也需要等待,这时该锁实际上就变成了互斥锁。这种情况常见于下面两种场景:

1) 线程1持有写锁,线程2尝试获取读锁。

线程1持有写锁时,线程2需要等待直到线程1释放写锁。这种行为确保了在有写者的情况下,其他线程(包括读者)将被阻塞。这种等待直到写锁释放的模式类似于互斥锁的行为。    

2) 线程1持有读锁,线程2尝试获取写锁,后续线程3也尝试获取读锁:

线程1持有读锁时,线程2尝试获取写锁而处于等待状态。由于写者优先级高于读者,因此线程3即使尝试获取读锁也会处于等待状态,直到写锁被释放。这保证了在需要修改共享数据时,写者优先级最高,而后续读者需要等待写锁释放后才能获取读锁。

因此,即使是一个读写锁,在特定条件下也可能会表现出类似于互斥锁的行为,以保证对共享资源操作的正确性和一致性。

2.4anon_vma->rwsem  

Linux内核内存紧张时会进行内存回收。内存回收通过反向映射rmap机制找到page所有映射的vma并进行解除映射。对匿名页而言,page找到vma的路径一般如下:page->av(anon_vma)->avc(anon_vma_chain)->vma,其中avc起到桥梁作用。

anon_vma简称av, 用于管理匿名页vma,  当匿名页需要解映射时需要先找到av,再通过av进行查找处理。struct anon_vma_chain,简称avc。主要用于链接vma和av。

这几个重要数据结构的关系如下图:    

67a55131641f19d3f9dae5201dd6d8d6.jpeg

anon_vma定义了一组红黑树,vma中数据结构维护了avc,当需要访问av中的红黑树数据和vma中的avc时,需要锁保护。

anon_vma->rwsem是定义在anon vma数据结构中的读写信号量。

struct anon_vma {

       struct anon_vma *root;              /* Root of this anon_vma tree */

       struct rw_semaphore rwsem;       /* W: modification, R: walking the list */

}

获取锁的接口主要是anon_vma_lock_write和anon_vma_lock_read。当然也有带try类型的。这里不赘述。

static inline void __sched __down_write(struct rw_semaphore *sem)

{

       rwbase_write_lock(&sem->rwbase, TASK_UNINTERRUPTIBLE);        

}

static inline void anon_vma_lock_write(struct anon_vma *anon_vma)

{

       down_write(&anon_vma->root->rwsem);

}

static inline void anon_vma_lock_read(struct anon_vma *anon_vma)

{

       down_read(&anon_vma->root->rwsem);

}

可以看到,如果anon_vma的rwsem没有获取成功(已有其它线程持有)。当前进程会设置为TASK_UNINTERRUPTIBLE。这也是systrace中我们有时可能看到block reason为anon_vma_lock_write的D状态(uninterruptable sleep)的原因。

典型的在fork线程时dup_mmapàanon_vma_forkàanon_vma_lock_write持写锁路径:    

69dbed05f5e966b81c526f924a019522.png

内存回收对匿名页进行反向映射是需要持读锁的典型路径:

static struct anon_vma *rmap_walk_anon_lock(struct folio *folio,

                                       struct rmap_walk_control *rwc)

{

       struct anon_vma *anon_vma;

       if (rwc->anon_lock)

              return rwc->anon_lock(folio, rwc);

       anon_vma = folio_anon_vma(folio);

       if (!anon_vma)        

              return NULL;

       if (anon_vma_trylock_read(anon_vma))

              goto out;

       if (rwc->try_lock) {

              anon_vma = NULL;

              rwc->contended = true;

              goto out;

       }

    //获取读锁

       anon_vma_lock_read(anon_vma);

out:

       return anon_vma;

}

static void rmap_walk_anon(struct folio *folio,        

              struct rmap_walk_control *rwc, bool locked)

{

       struct anon_vma *anon_vma;

       pgoff_t pgoff_start, pgoff_end;

       struct anon_vma_chain *avc;

       if (locked) {

              anon_vma = folio_anon_vma(folio);

              /* anon_vma disappear under us? */

              VM_BUG_ON_FOLIO(!anon_vma, folio);

       } else {

        //获取读锁

              anon_vma = rmap_walk_anon_lock(folio, rwc); 

       }

       if (!anon_vma)

              return;

    //遍历找VMA过程需要锁保护

       pgoff_start = folio_pgoff(folio);

       pgoff_end = pgoff_start + folio_nr_pages(folio) - 1;

       anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root,

                     pgoff_start, pgoff_end) {

              struct vm_area_struct *vma = avc->vma;

              unsigned long address = vma_address(&folio->page, vma);

              VM_BUG_ON_VMA(address == -EFAULT, vma);

              cond_resched();

              if (rwc->invalid_vma && rwc->invalid_vma(vma, rwc->arg))

                     continue;

              if (!rwc->rmap_one(folio, vma, address, rwc->arg))

                     break;

              if (rwc->done && rwc->done(folio))        

                     break;

       }

       if (!locked)

        //释放锁

              anon_vma_unlock_read(anon_vma);

}

2.5mapping->i_mmap_rwsem  

上一节介绍了匿名页反向映射需要的锁,同样,文件页在反向映射也需要对应的锁,它就是mapping->i_mmap_rwsem。page结构体中有mapping成员。借助此成员,可以方便快捷地找到对应匿名页或文件页。在page->mapping不为0的情况下,如果第0位为0,说明该页为匿名页,此mmaping指高一个anon_vma结构变量;如果第0位不为0,则mapping指向一个每个file一个的address_space地址空间结构变量。

struct address_space结构体中的i_mmap 是一个优先搜索树,关联了这个文件 page cache 页的 vm_area_struct 就挂在这棵树上。当文件页反向映射时,需要通过page--->i_mmap--->vma这个查找顺序并解除对应映射。

struct address_space结构体中定义了i_mmap_rwsem读写信号量锁,用于保护i_mmap。    

struct address_space {

       struct inode              *host;

       struct xarray              i_pages;

       struct rw_semaphore       invalidate_lock;

       gfp_t                     gfp_mask;

       atomic_t              i_mmap_writable;

#ifdef CONFIG_READ_ONLY_THP_FOR_FS

       /* number of thp, only for non-shmem files */

       atomic_t              nr_thps;

#endif

       struct rb_root_cached       i_mmap;

       unsigned long              nrpages;

       pgoff_t                     writeback_index;

       const struct address_space_operations *a_ops;

       unsigned long              flags;

       errseq_t              wb_err;        

       spinlock_t              i_private_lock;

       struct list_head       i_private_list;

       struct rw_semaphore       i_mmap_rwsem;

       void *                     i_private_data;

} __attribute__((aligned(sizeof(long)))) __randomize_layout;

典型的获取i_mmap_rwsem锁的接口是i_mmap_lock_write和i_mmap_lock_read。获取写锁的路径举例如下:

63ee3bbedbcae7e362f28cd6a4a4d6a5.png

内存回收对文件页进行反向映射是需要持读锁的典型路径:

static void rmap_walk_file(struct folio *folio,

              struct rmap_walk_control *rwc, bool locked)

{

       struct address_space *mapping = folio_mapping(folio);        

       pgoff_t pgoff_start, pgoff_end;

       struct vm_area_struct *vma;

       /*

        * The page lock not only makes sure that page->mapping cannot

        * suddenly be NULLified by truncation, it makes sure that the

        * structure at mapping cannot be freed and reused yet,

        * so we can safely take mapping->i_mmap_rwsem.

        */

       VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio);

       if (!mapping)

              return;

       pgoff_start = folio_pgoff(folio);

       pgoff_end = pgoff_start + folio_nr_pages(folio) - 1;

       if (!locked) {        

              if (i_mmap_trylock_read(mapping))

                     goto lookup;

              if (rwc->try_lock) {

                     rwc->contended = true;

                     return;

              }

        //持读锁

              i_mmap_lock_read(mapping);

       }

lookup:

    //遍历i_mmap,需锁保护

       vma_interval_tree_foreach(vma, &mapping->i_mmap,

                     pgoff_start, pgoff_end) {

              unsigned long address = vma_address(&folio->page, vma);

              VM_BUG_ON_VMA(address == -EFAULT, vma);        

              cond_resched();

              if (rwc->invalid_vma && rwc->invalid_vma(vma, rwc->arg))

                     continue;

              if (!rwc->rmap_one(folio, vma, address, rwc->arg))

                     goto done;

              if (rwc->done && rwc->done(folio))

                     goto done;

       }

done:

       if (!locked)

        //释放锁

              i_mmap_unlock_read(mapping);

}

2.6shrinker_rwsem  

6.9内核shrinker_rwsem已经被改为mutex了。这里呈现原来的shrinker_rwsem使用及问题,因此这部分基于6.6内核进行分析。后续的案例会分析6.9内核针对这个锁的优化改动。

 shrinker是一把全局的读写信号量锁。

DECLARE_RWSEM(shrinker_rwsem)

shrinker_rwsem锁主要用于保护shrinker_list链表。驱动可能会注册相应的shinker以便在内存回收时被回调进行shrinker其内存。在注册时会将对应的shrinker加到全局的shrinker->list上。在内存紧张进行内存回收时会遍历shrinker->list的shrinker进行回调。因此为处理并发问题,需要锁保护。shrinker->rwsem主要就是确保这两个过程的并发。

获取写锁的典型路径:

void register_shrinker_prepared(struct shrinker *shrinker)

{

       down_write(&shrinker_rwsem);        

       list_add_tail(&shrinker->list, &shrinker_list);

       shrinker->flags |= SHRINKER_REGISTERED;

       shrinker_debugfs_add(shrinker);

       up_write(&shrinker_rwsem);

}

获读锁的典型路径:

static unsigned long shrink_slab(gfp_t gfp_mask, int nid,

                             struct mem_cgroup *memcg,

                             int priority)

{

       unsigned long ret, freed = 0;

       struct shrinker *shrinker;

       /*

        * The root memcg might be allocated even though memcg is disabled

        * via "cgroup_disable=memory" boot parameter.  This could make        

        * mem_cgroup_is_root() return false, then just run memcg slab

        * shrink, but skip global shrink.  This may result in premature

        * oom.

        */

       if (!mem_cgroup_disabled() && !mem_cgroup_is_root(memcg))

              return shrink_slab_memcg(gfp_mask, nid, memcg, priority);

    //获取shrinker_rwsem读锁

       if (!down_read_trylock(&shrinker_rwsem))

              goto out;

    //遍历shrinker_list进行回调,需要锁保护

       list_for_each_entry(shrinker, &shrinker_list, list) {

              struct shrink_control sc = {

                     .gfp_mask = gfp_mask,

                     .nid = nid,

                     .memcg = memcg,

              };        

              ret = do_shrink_slab(&sc, shrinker, priority);

              if (ret == SHRINK_EMPTY)

                     ret = 0;

              freed += ret;

              /*

               * Bail out if someone want to register a new shrinker to

               * prevent the registration from being stalled for long periods

               * by parallel ongoing shrinking.

               */

              if (rwsem_is_contended(&shrinker_rwsem)) {

                     freed = freed ? : 1;

                     break;

              }

       }

    //放锁

       up_read(&shrinker_rwsem);        

out:

       cond_resched();

       return freed;

}


3典型优化案例总结  

3.1Linux开源社区优化案例  

3.1.1案例1:per memcg lru_lock优化1  

自内核引入memcg以来,系统单一的lru list已经分成了每个内存组一个lru list,由每个内存组单独管理自己的 lru lists。但per memcg lru lock却没有同时引入,导致lru锁在不同memcg上的竞争十分激烈。社区中per-memcg LRU lock的补丁集为每个memcg引入了lru lock,来解决这个存在已久的问题。

阿里巴巴的Alex Shi在2020年提交了一组patchset优化此问题,在linux5.11合入主线。    

814998c392e0687a6c1f6fcb1b559996.png

通过这组patchset的修改,获得了62%的性能提升。

da70f02fbd66cd9514e6f35f02116172.png

这个改动主要是是通过大锁化小锁。

优化的核心如下:

1)在lruvec增加了per memcg的spinlock_t类型的lru_lock成员。    

21d80aa5db4d853315d5232a197555b4.png

2)对给定的page,可以获取对应memcg 的lruvec的lru_lock并返回对应lruvec。

2b949cc18517c2d9588aafef3de03126.png

3)原来需要获取node的lru_lock的地方,换成了获取memcg的lru_lock。

1f3b09252143eded9210e1bcba2080e9.png

3.1.2案例2:mmap_lock优化之IO fault path 路径锁优化2  

在内核中经常发生优先级翻转的问题。例如一个进程在遍历/proc/pid节点下的任何信息,可能会获取mmap_lock读锁,但如果一个低优先级任务获取了mmap_lock写锁,或正在执行耗时操作,这对高优先级进程的性能明显产生了影响。    

Josef Bacik在2018年提交了一组patchset, 在page fault发生IO耗时操作路径上尽快释放mmap_lock锁,相关处理借助page fault现成的retry机制完成。在linux5.1内核进入主线。

b54f054bd20e6651eb461f5e9f3a3eb7.png

此组patchset几个关键修改点:

1)增加page fault的cached_page,retry时handle_mm_fault_cacheable直接处理

8d42021120472162e545d7f99d2694ac.png

02fb809bcae386a7c21847ae179f879a.png

2)重新设计文件映射page fault路径,以便在可能执行 IO 或长时间阻塞的任何时间点释放 mmap sem(mmap_lock)

新增加maybe_unlock_mmap_for_io,在可能执行IO操作时调用,可能会释放mmap_sem锁。    

67985ca978d9edc94371c9d24243a7a4.png

b2d2d123c28f1296d241cebd69d65912.png    

耗时的IO还没有完成,释放mmap_sem锁,返回VM_FAULT_RETRY重载获取锁再进入此路径。

801b04be19b6c2424a9f0d1cf6b575c1.png

3.1.3案例3:mmap_lock优化之SPF优化3  

SPF是Speculative page-fault handling的缩写,中文是投机性缺页。它的主要思路是先尝试不需要获取mmap_sem(mmap_lock)进行page fault操作,能进行这个的前提是在整个page fault过程vma没有发生变化,一旦发生变化,这个操作就是白干的,需要重载获取mmap_sem进行正常操作。    

SPF改动最早出现在2010年左右,由Peter Zijlstra提交handle page fault without holding the mm semaphore的patchset,但由于还存在一些问题,直到2019年IBM的Laurent Dufour修复了相关问题,重启了这项工作。但此patchset一直没能merge进内核主线。

据描述,优化效果如下:

在Android平台进行测试,应用程序启动时间平均提升6%,一些大型应用程序(100 个线程以上)启动时间提高了20%。

c73f44e3db26cf7049b43aeb59699688.png

Dufour提交patset如下:    

6c7d6b7457c9b7d8559fa40731a1735c.png

关键修改点简介:

1)引入vma vm_sequence计数,当vma发生变化时,进行计数。在VMA修改的地方引入seqlock, 在进入临界区前调用vm_write_begin, 出临界区再调用vm_write_end, 两者都会对vma->vm_sequence增加计数。因此如果发生发VMA修改,可以通过获取vma->vm_sequence来判断。

89718e08d26524e5585b4a8e9511b6b2.png

3201306df3b269139b564c429165bc2f.png

fc7c98d23f948bb4349acd8e54d224de.png

2)新增__handle_speculative_fault接口,page fault时会先尝试__handle_speculative_fault。Spf处理如果vma的vm_sequence在整个处理过程没有发生计数,即vma没有被修改。可以不需要mmap_sem完成page faut,如果发生变生,返回VM_FAULT_RETRY重来一次获取mmap_sem的操作。    

f63e6049e5d69e5312b959daf26c56a1.png    

2afd3f596716649b5173fdad7a9cee63.png    

c2e71e1a27009686d4a67025d6d2f25b.png    

679d5e7eb8d18efe841d29e652325044.png

3)需要对应体系结构支持,如arm64在处理page fault前,先走SPF流程,如果成功直接完成page faut, 如果不成功,就走原来的持mmap_sem流程。

4f91e4eab8a7a417eee8209e6a3b4a87.png

3.1.4案例4:mmap_lock优化之PVL优化4  

mmap_sem锁最大的问题是一个进程级别的大锁,锁保护的范围太大,导致本应该可以并发进行的一些操作,无法进行并行。例如,线程1 page fault访问的vma1,线程2 mmap操作的是vma2,这两个vma不一样,本来完全可以进行并发,但由于都被mmap_sem锁保护导致无法并行。    

Google的Suren Baghdasaryan在2023年提交一组patchset,引入了per vma lock(PVL),通过per vma lock保护vma的修改,不同vma访问可以并发进行。在linux6.4进入主线。

通过per vma lock, 一些benchmark测试显示,收益稍差于SPF,可以达到SPF约75%效果

Performance benchmarks show similar although slightly smaller benefits as

with SPF patchset (~75% of SPF benefits). Still, with lower complexity

this approach might be more desirable.

另外在Android上一些多线程并发的APP(约100个线程)的启动时间最大可以优化20%

88438710656ef720bb06f4ce7853cb4f.png    

Patchset提交如下:

415531cf69343e41de39a270bd5c549b.png

关键修改点简介如下:

1)每个描述VMA(用vm_area_struct描述)里面实际增加了一个vm_lock_seq成员和lock锁。struct mm_struct新增加 mm_lock_seq成员。当对VMA进行写时,会增加vm_lock_seq计数,通过vm_lock_seq和mm_lock_seq的比对,可以判断是否有人正在持写锁写VMA。    

f89841c48fa454cda82db0ad52544d5c.png

2)封装了vma_write_lock和vma_read_try_lock接口。

vma_write_lock写锁获取由接口,它首先会取vma->lock锁,然后向vma->vm_lock_seq中写入vma->vm_mm->mm_lock_seq。就马上释放vma-> lock锁了。    

01137510c55a93e5bfe3e15d257d3663.png

进程级mm->mm_lock_seq会在vma_write_unlock_mm的时候增加1。

f831b718cb4a81966c0a2a0838290595.png

vma读锁获取由接口vma_read_trylock 完成,它判断vma->vm_lock_seq是否与vma->vm_mm->mm_lock_seq相等,如果相等,表示有人获取写锁。否则拿到vma->lock,与写锁不同的是,在这个过程一直持有vma->lock锁,直到完成时通过vma_read_unlock释放。    

8c245de3e0bbccb3959317243833b4d0.png

5aa5936c1f3dbf620158c9d065ec2b3c.png

2) mmap写修改地方,由vma_write_lock标记。    

3ce9039429b668cd7689e08230a5ee22.png

3)try VMA lock-based page fault handling first

lock_vma_under_rcu调用尝试获取vma读锁。如果vma seq 和mm seq相等,表明有人正在进行写操作,回退使用mmap_lock。否则获取vma的读锁进行page fault,完成后释放。    

a0999a553f2def3eff57b34042ce83df.png

这个优化是典型的大锁化小锁的优化。总结一下,通过此优化后的场景锁的使用情况:

1)、当thread1处理vma1需要持write锁时,需要持mm->mmap_lock;此时当thread2处理vma2需要持read锁时,因为mm->mm_lock_seq与vma2里保存的vma>vm_lock_seq不同,所以thread2处理vma2不需要持mm->mmap_lock,因此也不需要等待thread1去释放mm->mmap_lock,从而thread1和thread2可以并行处理;同理:与此同时,thread3处理vma3、thread4处理vma4需要持读锁的场景也不需要。

2)、当thread1处理vma1需要持write锁时,同时thread2处理vma1也需要持read锁时,两者就转换成去持mm->mmap_lock读写锁逻辑,必须等待thread1写完成或者thread2读完成,才能唤醒另外的thread执行。    

第一点优化了更大程度增加了同一地址空间不同vma并行处理能力,在处理同一vma时转换成原来的处理方式,也没有不会带来任何额外性能损耗。

3.1.5案例5:mmap_lock优化之fault around优化5  

mmap_lock锁竞争最大的是发生在page fault,如果可以减少page fault,就可以从源头上减少对mmap的竞争。2016年Vinayak Menon就在内核对fault_around_bytes提交了可配置的patch.

       通过把fault_around_bytes配置来优化性能,但也需要平衡内存压力。此参数默认此参数是64K。Vinayak Menon提供的一些测试结果可以参考如下:    

31d4306973f793e4407bd9899aa848a2.png

Patchset参数提交列举如下:    

f7bea48adf3e36166beea0ad5fa98247.png

它优化的思路是在文件页缺页时会把当次附近地址的一起建立映射(大小可按需求配置),它只会对本身存在page cache的提前建立映射,并不会产生page cache。

3.1.6案例6:mmap_lock优化之unmap优化6  

当上层在用户态调用free时,底层调用munmap,但是munmap代价并不小,因为它需要持mmap_sem的write锁后解除所有的PTE映射并释放回内存伙伴系统。这个代价会随着VMA区域长短增大成线性增加关系。例如unmap 320GB 需要约 18 秒。

阿里巴巴的yang.shi在2018年提交一组分段unmmap的patchset。    

30c3e21ee229050aab8becc957a35a03.png

关键修改很简单,就是对大于HPAGE_PUD_SIZE(1GB)的,每次unmmap HPAGE_PUD_SIZE大小,然后判断一下mmap_sem是否有waiter,并且当前处于可睡眠上下文,主动释放mmap_sem并让出CPU,然后下一次重新获得CPU运行时再次获取mmap_sem继续进行unmap。收益如下:    

d6077f024ad226bf99266b9c5657d86d.png

此pathset未进入主线。

大部分人相比munmap更喜欢使用madvise_dontneed调用, 它不需要持mmap_sem的写锁,只需要持mmap_sem的读锁。因此进程中其它线程发生缺页时,仍然可以并发处理,相对而言性能优于munmap。由于madvise_dontneed并不是立刻释放虚拟地址,它也有一些缺陷,可能会导致虚拟地址紧张(特别是对于32位应用)。

3.1.7案例7:rmap路径的锁性能优化7  

在系统内存紧张的场景同时有进程在持续处理它们的vma工作如fork, mmap,munmap等行为时,rmap反向映射路径上的锁如i_mmap_rwsem和anon_vma->root->rwsem会存在明显的竞争。它可能会使内存回收路径性能较差或者卡住。在一些观察中,可以看到kswapd等待这些锁的耗时在300ms以上, 最坏的情况下,可以达到1s以上,这使得其它一些进程进入direct recalim, 当然,进入direct reclaim也会卡在这些锁上面。

       2022年minchan提交了一组patchset优化种情况的问题,优化补丁已在5.19合入主线。合入补丁后,可以看到在rmap路径上的平均耗时大幅下降。

28bbbc43db8398c22710c2aca0b36f6b.png    

patchset提交如下:

58f0488cc0a93482c629422ea66d14b2.png

关键修改简介:

1)封装了i_mmap_rwsem和anon_vma->root->rwsem锁的trylock_read接口。

7c0a9244f4d1d43e6e769d82d6e2c525.png

d73971fb1b999594e8c39b354ea7ff02.png

2)在rmap时先进行trylock,在trylock成功时,按原来流程处理 。trylock不成功时,表明锁存在竞争,设置rwc->contended为true然后返回。    

8d8f6bd15f450ca6d8f6e933977f99a2.png

d75976e4d785133763b3ed6c3920d153.png

3)在folio_check_references如果referenced_ptes为-1即rmap的锁存在竞争,trylock不成功,跳过此page的处理。

9066fd59d77f16fa1e9e014d051f5a0c.png

3.1.8案例8:shrinker lockless优化8  

shrinker_rw是一把全局的读写锁,用于保护一些操作例如shrink slab, shrinker registration和shrinker的unregistration等。这些都容易存在性能问题。例如:    

1)当系统内存压力大且同时系统文件系统存在mount和unmount时,shinrk slab会受到影响(down_read_trylock失败)

2)如果一个shrinker被blocked住(例如上面1描述的情况且一个写者来临,写者会被blocked,然后所有shriner相关操作也会被blocked)

例如,当一个driver进行register一个shrinker时,它需要写锁,如果shirnk slab在内回收时,由于runnable或D状态等长时间没释放此锁,那么register的进程需要长时间等待。同样,如果一个进程在unreginer一个shrinker时,由于runnable较长时间没有释放锁,shrink slab由于获取不到锁,内存回收也明显的受到影响。

字节跳动的zhengqi在2023年09月提交了一组patchset,借助refcout计数和RCU将全局shinker锁优化为无锁操作。patchset在linux6.7合入主线。

patchset提交如下:    

a88ba16ec849d866251129db05211902.png

合入patch后,可以看到性能明显提升,收益数据如下:    

e7cc05ea9c7d82fff7528a7e02f12dff.png

关键修改简介如下:

1)封装了shrinker_alloc、shrinker_register、shrinker_free接口。    

346e3644ce75363ae39f75f167caf368.png    

dfacfe6900cd69f6a3afb61a7f5614f7.png

2)改造原来直接调用register_shrinker的地方。    

e95d57cab18b37dacba3e9d4f39a9feb.png

3)Shrinker新增refcount、struct completion done、struct rcu_head rcu成员。

Refcount会在shrinker Registered的时候拥有初始值1,然后一些查找操作将被通过shrinker_try_get允许使用它。然后在unregistration的时候Refcount会进行计数的减少,当其为0时,会通过异步的RCU来释放shrinker。

在原来获取shrinker读写锁的地方,现在先通过获取shrinker_try_get增加shrinker->refcount引用计数,完成shrinker操作后通过shrinker_put减少shrinker->refcount引用计数。

d062f83c3d8d21f7daa31858edb1e41e.png

a4668e98c48ff2c12cbd879e9dbc100f.png    

814ba71382f0801414e8fea9717d01fb.png

当shrinker->refcount引用计数减少时,如果到达到0,即没有进程在使用,就设置shrinker->done complete操作。

1444f4181274bfcf2a58a56465aa1919.png

c20664076d0aa074493a476df1c9c03b.png

shrinker->done条件满足,即没有进程在使用shrinker了,通过rcu cb进行删除释放。    

439eb180300400993c752831a2bee5f5.png

4)在确认没有reader, shrinker_rwsem替换为mutex锁    

4a1460aa38dd3a95a747147678bae979.png


4.优化演进方向总结  

通过上面案例,可以看到在内存锁(操作系统锁的方向也一样)在优化的演进和方向上主要是以下优化方向:

1)lock less无锁操作。要想优化锁的性能问题,最根本的改善办法就是无锁操作。可以参考上面案例中的shrinker lockless案例和SPF案例。

2)缩小临界区,尽早释放锁。如果锁无法避免,需要避免长时间持锁的临界区,可以考虑先释放锁,完成耗时操作后,再次获取锁完成此过程。可以参考IO fault path 路径锁优化和munmap优化的案例。

3)大锁化小锁。把大锁拆成小锁,减少锁竞争的粒度。可以参考PVL优化案例和per memcg lru lock优化案例。

4)降低锁等待的影响。在锁竞争激烈时,尽量不让等待锁影响关键路径的性能。可以参考上面rmap路径的锁性能优化的案例。

5)减少持锁次数。通过一些提前操作的方式或一次性多操作的方式,或从业务源头减少相关持锁操作方式,降低持锁次数。可以参考上面fault around案例。    


5.参考资料  

1.https://patchwork.kernel.org/project/linux-mm/cover/1604566549-62481-1-git-send-email-alex.shi@linux.alibaba.com/

2.https://patchwork.kernel.org/project/linux-mm/cover/20180925153011.15311-1-josef@toxicpanda.com/

3.https://patchwork.kernel.org/project/linux-mm/cover/20190416134522.17540-1-ldufour@linux.ibm.com/

4.https://patchwork.kernel.org/project/linux-mm/cover/20230109205336.3665937-1-surenb@google.com/

5.https://lore.kernel.org/all/20160422141716.GD7336@node.shutemov.name/T/

6.https://lore.kernel.org/lkml/f88deb20-bcce-939f-53a6-1061c39a9f6c@linux.alibaba.com/

7.https://patchwork.kernel.org/project/linux-mm/patch/20220510215423.164547-1-minchan@kernel.org/

8.https://patchwork.kernel.org/project/linux-mm/patch/20230911094444.68966-43-zhengqi.arch@bytedance.com/

9.https://elixir.bootlin.com/linux/v6.9.7/C/ident/    

26461489ce8e7bf3c0b5a0bedc9a8744.jpeg

virtio虚拟化框架概述

Binder驱动中的流程详解

2024年Arm最新处理器架构分析——X925和A725

484ec4b75371019b8720886b33510a70.gif

长按关注内核工匠微信

Linux内核黑科技| 技术文章| 精选教程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

OPPO内核工匠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值