引用计数的思想在资源管理中被广泛使用,核心算法是每一个使用者在使用资源时都对引用计数加1,在使用结束的时候对引用计数减1,当引用计数减到0的时候,可以认为该资源没有人在使用,也不应当被再次使用,于是就可以放心销毁,回收内存。每一个新创建的结构体中都含有一个计数器,该计数器可以用原子操作进行加减,资源的释放就发生在引用计数第一次减到0的时候。
但是使用原子操作进行加减这个看起来很高效的做法在SMP的架构下逐渐暴露弊端,因为这样加减需要锁总线,在x86上就是在指令的前面加lock前缀。该lock前缀会导致一个核心的操作影响到其他所有的CPU,在CPU管线上的体现就是内存访问CPU的后端的速度受限。这种传统的引用计数方式就是普通的ref。
从实际使用上看,大部分对于资源的自动回收管理都有一个初始的统一的位置,例如,一个进程打开文件的描述符表,一个文件无论外部的逻辑引用了多少次文件,只要该文件仍然在文件描述符表中,其他的增加/减少引用计数就都不可能触发该文件结构体的销毁。这时文件的引用计数就有中心的概念,只有中心持有者决定不持有该引用,其他持有者才有可能将引用计数降低到0,从而触发内存回收。如果在这之前的引用计数的增减仍然使用lock前缀的锁总线操作,就会增加不必要的成本。内核实现了perfcpu-ref的引用计数方法(/linux/lib/percpu-refcount.c)。
perfcpu-ref引用计数的增加和减少都使用每CPU变量,但只有登记的作用,并不会实际触发资源回收。由于增加引用计数和减少引用计数都是成对出现的,所以即使在一个CPU上增加引用计数,在另外一个CPU上减少引用计数,也能保证所有每CPU变量的值的和是正确的。也就是说,CPU上的这个引用计数可以是负数。只有当核心持有者决定释放的时候,整个引用计数才会转换为普通的原子变量,并且会先进行每CPU变量的累加计算,确定当前的引用计数值是原子变量的初始值。后面的资源释放与普通的ref一样,等到引用计数减少到0的时候触发销毁。
引用计数切换的过程与RCU锁面临一个同样的问题,就是在切换的时候不能确保当前没有每CPU变量的操作,在切换发生的时候每CPU变量的引用计数的增加必须是原子的,所以内核实现的percpu-ref中引入了RCU锁来解决这个问题。以下是percpu-ref的数据结构的定义。
笔者为了区分快速路径和慢速路径,设计了二级的结构。在主持有者存续期间,只有percpu_count_ptr指向的每CPU变量是被用到的,且只有在调用percpu_ref_kill()函数释放了主持有者,变成普通的原子类型的引用计数,data中的数据才开始有效。
percpu_count_ptr和data都在初始化的时候就会分配内存,所以可见当前的实现对于频繁创建释放的结构体并不是一个很好的选择,其引用计数层面节省的算力可能会被在内存管理上额外增加的负担所平衡,而且相比普通ref还增加了很多的内存占用。
percpu_count_ptr是指向每CPU的变量,最后两位代表当前引用计数的状态,状态有三种,代码如下。
最后两位全是0的时候代表当前工作在每CPU变量更新的模式;最后一位是1的时候代表现在已经工作在原子操作模式;倒数第2位是1的时候就代表当前已经离开每CPU模式,切换到原子更新模式后两个位都会被设置。笔者将从每CPU变量更新模式切换到原子更新模式的过程叫作死亡过程,离开每CPU变量更新模式的操作叫作kill,对应的函数是percpu_ref_kill(),定义如下。
在上述函数中,切换操作需要加锁关中断,这一步的成本比较高,对于通用数据结构,这样写可以保证正确性,但是对于性能单位,这样写有些浪费,关中断的操作在中断处理函数中没有获得percpu_ref_switch_lock锁操作时是多余的。因为percpu_ref_switch_lock是全局自旋锁,也就是说整个内核中的所有percpu-ref都公用同一个自旋锁,这无疑是一个很大的性能瓶颈。同时出现的全局变量还有通知事件数据percpu_ref_switch_waitq,如果这个数据不设计成全局的,就会加深struct percpu_ref结构体的复杂度,如果设计成全局的就会带来互不相关的引用计数之间的互斥。但是如果同时发生大量不同的引用计数的模式切换,就会产生并发冲突。
如果__percpu_ref_switch_mode的当前状态中有__PERCPU_REF_DEAD状态或者强制切换到原子更新模式,就会切换到原子更新模式,如果没有_PERCPU_REF_DEAD状态,就会切换到每CPU变量更新模式。这意味着perfcpu-ref可以支持从原子更新模式切换回每CPU更新模式,对应的函数是percpu_ref_resurrect(struct percpu_ref *ref)。
data中的域有很多,获得一个引用计数的函数是percpu_ref_get,该函数的实现如下。
更新的操作是比较轻量级的,根据两种不同的模式分别进行两种更新。语义是只要离开每CPU更新模式,剩下的两种状态就是原子更新的模式。
这里读侧的锁使用了READ_ONCE而没有使用RCU锁语义的rcu_dereference。
对于切换的过程,分为默认的异步切换和同步的阻塞切换。
延续上面的percpu_ref_kill()函数的调用,__percpu_ref_switch_mode函数的定义如下。
__percpu_ref_switch_mode函数要确保percpu_ref_switch_lock锁在持有的情况下调用,等待data-> confirm_switch中为空则代表一次状态切换完成,因为在状态切换的时候需要设置这个域为使用者提供的confirm_switch函数,若使用者没有提供,则使用默认的无操作函数,只有切换完成该域才会变成NULL值。所以这里的阻塞等待data-> confirm_switch变成NULL实际上就是等待并发的切换操作完成,如果没有并发的切换发生,这一步相当于空操作。
如果是强制原子操作(可以在初始化时设置或者使用专门的强制模式变更API)或者设置了__PERCPU_REF_DEAD,就会切换到原子更新模式,否则就会切换到每CPU更新模式。如果使用正常的percpu_ref_kill()入口,这里就会进入切换到原子模式的confirm_switch函数,该函数的定义如下。
confirm_switch函数实际执行切换到原子更新模式的逻辑。由于状态转换的整个过程是异步的(RCU GP),只有启动转换的过程是原子的,所以可能其他的转换者先完成了转换。也就是wait_event_lock_irq会阻塞等待,在等待结束后,__percpu_ref_switch_to_atomic就可以直接返回了,如果调用者提供confirm_switch函数,confirm_switch函数就会被调用。confirm_switch函数的意义是在转换完成的时候调用回调函数。
如果当前函数需要继续转换,就会设置__PERCPU_REF_ATOMIC状态,并且将ref->data->confirm_switch设置为回调函数,在percpu_ref_kill_and_confirm函数会直接释放掉主引用计数,这里需要重新获得一个引用计数,防止在回调过程中引用计数归0。
在等待GP后调用percpu_ref_switch_to_atomic_rcu函数,完成实际的切换。实际的切换就是先累加每个每CPU变量的值到原子变量,然后调用ref-> data->confirm_switch里面注册的函数,再将指针清空为NULL。