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

文摘   2024-08-25 19:42   陕西  
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流程如下:

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为例,列举了锁的使用过程。

   

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的使用。

内核中的 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。
这几个重要数据结构的关系如下图:    
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持写锁路径:    

内存回收对匿名页进行反向映射是需要持读锁的典型路径:
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。获取写锁的路径举例如下:

内存回收对文件页进行反向映射是需要持读锁的典型路径:
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合入主线。    
         

 

通过这组patchset的修改,获得了62%的性能提升。
这个改动主要是是通过大锁化小锁。
优化的核心如下:
1)在lruvec增加了per memcg的spinlock_t类型的lru_lock成员。    
2)对给定的page,可以获取对应memcg 的lruvec的lru_lock并返回对应lruvec。
3)原来需要获取node的lru_lock的地方,换成了获取memcg的lru_lock。

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内核进入主线。
此组patchset几个关键修改点:
1)增加page fault的cached_page,retry时handle_mm_fault_cacheable直接处理
         

 

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

 

新增加maybe_unlock_mmap_for_io,在可能执行IO操作时调用,可能会释放mmap_sem锁。    
   
耗时的IO还没有完成,释放mmap_sem锁,返回VM_FAULT_RETRY重载获取锁再进入此路径。
         

 

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%。
Dufour提交patset如下:    
关键修改点简介:
1)引入vma vm_sequence计数,当vma发生变化时,进行计数。在VMA修改的地方引入seqlock, 在进入临界区前调用vm_write_begin, 出临界区再调用vm_write_end, 两者都会对vma->vm_sequence增加计数。因此如果发生发VMA修改,可以通过获取vma->vm_sequence来判断。
         

 

   
         

 

         

 

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

 

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

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%

    

 

Patchset提交如下:
关键修改点简介如下:
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。    
         

 

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锁了。    
进程级mm->mm_lock_seq会在vma_write_unlock_mm的时候增加1。
         

 

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

 

2) mmap写修改地方,由vma_write_lock标记。    
3)try VMA lock-based page fault handling first
lock_vma_under_rcu调用尝试获取vma读锁。如果vma seq 和mm seq相等,表明有人正在进行写操作,回退使用mmap_lock。否则获取vma的读锁进行page fault,完成后释放。    
这个优化是典型的大锁化小锁的优化。总结一下,通过此优化后的场景锁的使用情况:
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配置来优化性能,但也需要平衡内存压力。此参数默认此参数是64KVinayak Menon提供的一些测试结果可以参考如下:    
         

 

Patchset参数提交列举如下:    
它优化的思路是在文件页缺页时会把当次附近地址的一起建立映射(大小可按需求配置),它只会对本身存在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。    
关键修改很简单,就是对大于HPAGE_PUD_SIZE(1GB)的,每次unmmap HPAGE_PUD_SIZE大小,然后判断一下mmap_sem是否有waiter,并且当前处于可睡眠上下文,主动释放mmap_sem并让出CPU,然后下一次重新获得CPU运行时再次获取mmap_sem继续进行unmap。收益如下:    
此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路径上的平均耗时大幅下降。
   
         

 

patchset提交如下:
关键修改简介:
1)封装了i_mmap_rwsem和anon_vma->root->rwsem锁的trylock_read接口。
2)在rmap时先进行trylock,在trylock成功时,按原来流程处理 。trylock不成功时,表明锁存在竞争,设置rwc->contended为true然后返回。    
3)在folio_check_references如果referenced_ptes为-1即rmap的锁存在竞争,trylock不成功,跳过此page的处理。
         

 

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提交如下:    
合入patch后,可以看到性能明显提升,收益数据如下:    
关键修改简介如下:
1)封装了shrinker_alloc、shrinker_register、shrinker_free接口。    
   
2)改造原来直接调用register_shrinker的地方。    
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引用计数。
   
         

 

当shrinker->refcount引用计数减少时,如果到达到0,即没有进程在使用,就设置shrinker->done complete操作。
shrinker->done条件满足,即没有进程在使用shrinker了,通过rcu cb进行删除释放。    
4)在确认没有reader, shrinker_rwsem替换为mutex锁    


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/    

Linux内核之旅
Linux内核之旅
 最新文章