從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

RzBu11023R發表於2021-08-26

    總結一下 O/S 課程裡面和鎖相關的內容. 本文是 6.S081 課程的相關內容總結回顧結合 Real World 的 Linux 講解各種鎖和 RCU lock free 機制原理, 前置知識是基本的作業系統知識以及部分組成原理知識:執行緒與併發的概念, 中斷與管態使用者態概念, 以及基本的併發程式設計鎖模型如讀寫鎖等和部分資料結構. 最好掌握的:快取記憶體一致性協議,CPU 亂序執行,記憶體屏障。

    RCU 部分涉及的論文和擴充套件閱讀:

    RCU Usage In the Linux Kernel: One Decade Later ,Paul E. McKenney,Jonathan Walpole

    https://www.kernel.org/doc/Documentation/RCU/whatisRCU.txt

    What is RCU, Fundamentally? [LWN.net]


共享資料結構的一致性(為什麼要做鎖?)

    對於 shared data structure, 需要保證讀寫的 critical section 時具備 consistency, 特別是讀的時候, 不希望讀到一個不完整的資料或者資料結構的不完整的結構. 比如一個連結串列在多個執行緒的讀寫過程中可能會出現的混亂的指標. 


單核本來就沒有並行 (誰需要鎖?)

    先談論 single core 的情況, 我們只需要通過關中斷 就可以實現 sequential access, 具體的思想實驗是如果一個結構正在被某個 thread (這裡的 thread 泛指 kernel 程式設計裡面的 process) 佔用, 我們希望他處理結束之後再 context switch, 這樣實際上不會出現共享訪問, 也就是沒有並行(對於共享結構本身就希望訪問並行的序列化). 也就是 local_irq_disable() 和 preempt_disable() 就能實現了. 某個資料結構一開始是未 locked 的, 一旦 locked 了就關閉 timer interrupt, 從而保證了在本執行緒結束前後的資料一致性.


多核爭用時自旋等待 (多核怎麼做鎖?)

    對於 multicore 的情況, 則需要考慮更多, 對於一個資料結構, 一旦他已經被一個 core 給 lock 了, 當前執行在另一個 core 上的 thread 就需要等待鎖釋放, 所以需要一個迴圈等待的過程, 叫做自旋. 具體實現的關鍵部分是通過 CPU 提供的一種 swap 指令, 在 RISC-V 上這個指令是 amoswap, 其功能是執行一個原子操作的讀出值和放入新值. 這樣只需要把 true 旋進去, 拿出來的如果是 true, 說明本來就被鎖了, 放個 true 進去不改變原始值, 如果拿出來是 false 說明當前 thread/core 拿到這個鎖. 我們看 xv6 裡面的實現:

void
acquire(struct spinlock *lk)
{
  push_off(); // disable interrupts to avoid deadlock.
  if(holding(lk))
    panic("acquire");
  // On RISC-V, sync_lock_test_and_set turns into an atomic swap:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;
  // Tell the C compiler and the processor to not move loads or stores
  // past this point, to ensure that the critical section's memory
  // references happen strictly after the lock is acquired.
  // On RISC-V, this emits a fence instruction.
  __sync_synchronize();
  // Record info about lock acquisition for holding() and debugging.
  lk->cpu = mycpu();
}  

    其中記憶體屏障做的事情在編譯器上很好理解就算防止編譯過程的指令重排導致的持有鎖狀態更新和鎖實際狀態的不一致, 而其在 CPU 上其實就是做一個等價於清空流水線的操作(具體實際上是涉及多核的 Cache 一致性,Kernel documentation 裡也有講解 memory barrier 的 txt 文件,下文也會提及一部分記憶體屏障的詳細介紹), 這一點我們在體系結構課程和 CSAPP 裡面的對於簡易流水線和 tomasulo 技術相關的地方學習過了. 值得提一下 spinlock 是約定不允許在 context switch (關閉中斷後只有 yield() 會引發) 時候持有的(這個下文也會講), 否則會有可能導致問題如 deadlock(當持有兩個鎖順序不同時), 這一點和關中斷的原因一樣, 顯而易見 (會導致別的共享資料結構的執行緒自旋無法獲取到鎖).


關搶佔實現效能保障 (怎麼推動儘快釋放鎖?)

    spinlock, 他的 overhead 有記憶體屏障導致的清空流水線浪費一個流水線長度, 然後主要就是迴圈等待的不斷 CAS 的過程, 還有一個就算這個 amoswap 涉及多核 CPU 的 cache coherence MESI 的東西. 當然這個單核時使用 spinlock 最簡單的就算一個退化過程, 不需要單獨編寫 (Linux 裡面對單核 CPU 和多核的方案當然不一樣). 他的效能則是由關搶佔來保證的, 關中斷後能夠使需要鎖的操作快速執行完, 防止拿到鎖後 context switch 出去導致別的執行緒/核心需要等該核心輪轉.

    補充一個要點,我後來才注意到 Linux spinlock 的實現:spinlock 持有 lock 之後關了 preempt 不關 interrupt。但是對於某些情況,有一個 irqsave 版本會關。irqsave 版本涉及的要點是:process 和 中斷都想獲取一個資源的時候,就要 avoid deadlock。


應用層自旋鎖效能捉急 (鎖的效能怎麼樣?)

    spinlock 也無法很好地用於應用層, 這是從語言 runtime 角度上看的, 他無法提供一個作業系統關中斷的方法, 也就是沒有上述程式碼這種模式中的 push_off() 部分, 回想編寫 spinlock 的過程, 我們依據關中斷實現序列化, 多核迴圈等待並且規定 context switch 時不能持有鎖來避免死鎖. 一些庫實現了應用態的 spinlock, 但是沒有關中斷的操作, 這樣的 spinlock 和上述的 spinlock 就不一樣了

    我們具體分析就是, 單核和多核下都可以通過 timer interrupt 來避免死鎖, 但是由於沒有關中斷, 從而無法保證需要持有鎖的任務會快速完成 (而這個是效能的關鍵). 即核心態 spinlock 讓一些核空轉等待,並督促持有核推進當前任務,而應用層 spinlock 只有空轉等待,沒有督促效應,持鎖核很有可能三心二意執行中途其他任務導致等待核持續空轉。


應用層自旋鎖如何把控效能 (怎麼做應用層的鎖?)

    改進使用者態 spinlock 可以參考 nginx 的 ngx_spinlock, 他通過用 ngx_cpu_pause() 來告知處理器優化 spin-wait loop 效能和單核下 ngx_sched_yield() 快速切換 (本段已經說了應用層的 spinlock 和 O/S 層的機制不一樣, 所以允許 context switch)。

    這裡詳細講解一下 nginx 的方案:  首先是 intel 的  PAUSE 指令, 就是spin-wait loop 由於有很多的 hazard 的  load  和  store  (至於 CAS 指令是原子的為什麼會導致 load 和 store 這一點想一下流水線本質執行的是精簡指令就能理解, 具體不深究), 容易使處理器的流水線指令重排機制認為出現 memory order violation, 所以要保證安全就要頻繁地清空流水線, pause 能避免大量迴圈後再 context switch。

    但是我們知道現代處理器用了巨量暫存器來進行提前計算,即 tomasulo 是不進行清空的, 我們通過最後指令 retire 之前進行 reorder, 而這個 reorder 本質上是一些邏輯電路在做, 也要佔用 CPU cycles, Intel 說會引發 25 倍效能損失, 所以這個 PAUSE 直接讓 CPU 執行一些  NOP  類似的效果(好像用 NOP 效果更好), 也就不需要 reorder 的這部分 overhead 了. Intel 還說 busy loop 會導致 CPU 發熱功耗增高, 這個涉及具體的 CPU 實現. 第二點是  yield , 也就是提前 yield, 這一點則很好思想實驗, 就是提前 timer interrupt 了而已. 部落格園知識庫還有一篇文章分析了 C runtime 的 spinlock 的改進, 主要是彙編程式碼, 值得一看 自旋鎖spinlock剖析與改進_知識庫_部落格園 (cnblogs.com).


睡眠鎖把輪詢型轉向通知型 (如何去掉迴圈浪費CPU?)

    spinlock 講到這裡了, 所以 busy-wait-loop 就很浪費 CPU cycles, 不止是應用層, kernel 裡也一樣. 對於長阻塞操作, 我們可以做 sleeplock, sleeplock 具備 sleep() 和 wakeup(), 很明顯是需要 O/S 支援的, 所以就是核心下的. 其具體實現很容易思想實驗, 我們必須在核心程式 PCB 建立一個 condition 的欄位用來儲存程式睡在誰上面, 然後就是 sleep syscall trap 進來 RUNNING 變 SLEEP 了. 針對多核對 sleep lock 的問題, 所以 sleeplock 本身以及 process controll block 本身都要受到 spinlock 的保護.

    然後我們要明確的是 sleeplock 的 sleep syscall 本身是可以被 preempt 或者其他東西 interrupt 的, 畢竟都在 process 的 kernel space 下了, 不會引發一致性問題, 當然涉及 PCB 和 sleeplock 本身的資訊是被 spinlock 鎖了的.

    sleeplock 是不能在 trap handler 裡面用的, 這是因為我們知道 PCB 具備 context, contex 要麼是其 kernel space 的要麼是 user space 的, 而 trap 的 vector 是在 trampoline 裡面 map 來跳轉的, handler 要實現呼叫 sched 還原 context, 其本身是 kernel 的 temp code, 並不具備 context, 更不用說支援多次中斷了. 所以也不能使用 sleeplock, 因為 wakeup 一個 process 並不能返回到 handler 的某個 context 裡.

// Atomically release lock and sleep on chan.
// Reacquires lock when awakened.
void
sleep(void *chan, struct spinlock *lk)
{
  struct proc *p = myproc();

  // Must acquire p->lock in order to
  // change p->state and then call sched.
  // Once we hold p->lock, we can be
  // guaranteed that we won't miss any wakeup
  // (wakeup locks p->lock),
  // so it's okay to release lk.
  if(lk != &p->lock){  //DOC: sleeplock0
    acquire(&p->lock);  //DOC: sleeplock1
    release(lk);
  }
  // Go to sleep.
  p->chan = chan;
  p->state = SLEEPING;
  sched();
  // Tidy up.
  p->chan = 0;
  // Reacquire original lock.
  if(lk != &p->lock){
    release(&p->lock);
    acquire(lk);
  }
}

void
acquiresleep(struct sleeplock *lk)
{
  acquire(&lk->lk);
  while (lk->locked) {
    sleep(lk, &lk->lk);
  }
  lk->locked = 1;
  lk->pid = myproc()->pid;
  release(&lk->lk);
}
void
releasesleep(struct sleeplock *lk)
{
  acquire(&lk->lk);
  lk->locked = 0;
  lk->pid = 0;
  wakeup(lk);
  release(&lk->lk);
}

Linux 中的 sleeplock —— semaphore (Real Word 分析)

    xv6 的 sleeplock 在 linux 核心中是叫做  mutex , 早期只有一個  semaphore  (具體兩者有一些差異, 主要是互斥數量吧) linux 的 mutex 實現十分的複雜, 涉及多種分類討論的機制, 如果具體學習很複雜沒有 xv6 這個簡單. 所以先看一下 Linux semaphore 的原始碼. 因為涉及一個分類討論的做法,所以回來補充了 mutex 的分析,講完 semaphore 就講 mutex。

void __down(struct semaphore* sem)
{
  struct task_struct* tsk = current;
  DECLARE_WAITQUEUE(wait, tsk);  //定義一個"佇列項", 等待者是當前程式
  tsk->state = TASK_UNINTERRUPTIBLE;
  add_wait_queue_exclusive(&sem->wait,
                           &wait);  //把當前程式新增到該訊號量的wait queue裡.
  spin_lock_irq(&semaphore_lock);  //抓取"大鎖"
  sem->sleepers++;
  for (;;) {
    int sleepers = sem->sleepers;
    /*
    * Add "everybody else" into it. They aren't
    * playing, because we own the spinlock.
    */
    if (!atomic_add_negative(sleepers - 1, &sem->count)) {  //臨睡前最後一次嘗試
      sem->sleepers = 0;
      break;
    }
    sem->sleepers = 1; /* us - see -1 above */
    spin_unlock_irq(&semaphore_lock);
    schedule();  //睡眠
    tsk->state = TASK_UNINTERRUPTIBLE;
    spin_lock_irq(&semaphore_lock);
  }
  spin_unlock_irq(&semaphore_lock);
  remove_wait_queue(&sem->wait, &wait);  //取得訊號量後, 退出該訊號量的等待佇列
  tsk->state = TASK_RUNNING;
  wake_up(&sem->wait);
}

    可以看到進行了多次嘗試, 最後在  schedule() 處進入了睡眠. 一旦恢復 RUNNABLE 並被 schedule 之後, 將會返回到 for 迴圈下一個迭代中獲取鎖, 這一點和 xv6 中 acquire 中一個 while 裡面 sleep 是一樣的程式碼結構, 當然 wakeup 之後並不一定能獲取鎖, 很有可能重新進入 sleep, 看誰搶的快了.

    Linux 的 mutex 則是基本原理和 semaphore 差不多,實現上覆雜一些。


 

Linux 中的 spinlock + sleeplock —— mutex(Real Word 分析)

    Linux 的 mutex 則是基本原理和 semaphore 差不多(一個是如其名只能讓 0 和 1 的公用即 mutex 互斥,一個則是 Dijkstra 訊號量),實現上覆雜一些(增加了多路徑優化)。

    我們將在後面講 Java 的鎖的實現時候看到,對於不同的情況 spinlock 和 阻塞的 sleeplock 的效能效果也不同。儘管是在核心裡用鎖沒有 trap 的開銷但仍然由 schedule() 導致的 contex switch,這也是 mutex 的另一個優化點,在具體談論怎麼做時,我們給出一個核心開發鎖選用的建議:

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解    圖片來自 Linux 核心開發一書。我暫時不瞭解為什麼 semaphore 沒有采用這個類似 Java 的縫合優化方案,也許他採用了我沒有具體讀懂 semaphore 的程式碼?如果你知道具體情況歡迎評論。以下再給出 Linux kernel 3.3 版本中的 mutex 原始碼 裡獲取鎖的程式碼的一個小片段:

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

     儘管我沒有具體截圖,但是觀看這段註釋相信就能想到這接下來的程式碼是什麼了,他甚至不用像 Java 那樣先試著 spin 10 次再 sleep,而是直接判斷一個鎖正在 SMP 機的另一個核上程式持有並執行者,就直接 spin 等待而不是 sleep。當然會有一個疑問,我們的 sleeplock 設計上是 preemp_on 的,不過這一點思想實驗也可以解決,只需要在 spin 的迴圈裡面判斷 owner switch 出去的話我就 break 從而順延到普通的 sleeplock 上。所以為什麼下文提到的 Java 不採用這種方案呢?可能和虛擬機器的多核多執行緒本質是由作業系統核心排程的有關,儘管可能是一對一的執行緒模型,但是 Java 並不能瞭解被排程執行的執行緒是否真實執行在 CPU 核心上也無法獲取 owner 執行緒是否 Running 的,所以這也是 Java 的執行緒狀態沒有 Running 只有 Runnable 的原因吧。


 

sleeplock 提供系統呼叫提高應用層鎖效能 (如何讓應用層也去掉輪詢?)

    於是再看到應用層的, 即使用者態下可以實現 spinlock, 那麼是否可以實現 sleeplock 呢? 答案是否定的, 我們無法在應用層下實現一個需要涉及 PCB 操作的功能. 但是可以在核心下實現 futex 供應用層使用.

    linux 下 c runtime 的  pthread_mutex  獲取鎖分為兩階段,第一階段在使用者態採用spinlock鎖匯流排的方式獲取一次鎖,如果成功立即返回;否則進入第二階段,呼叫系統的futex鎖去sleep,當鎖可用後被喚醒,繼續競爭鎖。

    這樣實現的好處是對於大部分情況, 不需要進入到 kernel space, 直接在 runtime 的 user space 就完成了. 對於確實需要等待的, trap 進 kernel sleep. 主要的 overhead 只是 trap 而已.  futex 是給 user program 做 syscall 的這個和 pthread 的使用者態 mutex 不一樣.

    應用層我們可以看 pthread 庫下的 mutex 實現原理, 當然這裡還涉及 pthread 的實現, 具體就不說了. 主要看這個模式, 是怎麼的思想方法. 可以看到死迴圈裡面如果沒有拿到鎖, 就會通過呼叫 suspend 來呼叫 futex 的 syscall trap 進去從而 sleep.

// 阻塞式獲取互斥變數
int __pthread_mutex_lock(pthread_mutex_t * mutex)
{
  pthread_t self;
  while(1) {
    acquire(&mutex->m_spinlock);
    switch(mutex->m_kind) {
    case PTHREAD_MUTEX_FAST_NP:
      if (mutex->m_count == 0) {
        mutex->m_count = 1;
        release(&mutex->m_spinlock);
        return 0;
      }
      self = thread_self();
      break;
    case PTHREAD_MUTEX_RECURSIVE_NP:
      self = thread_self();
      // 等於0或者本執行緒已經獲得過該互斥鎖,則可以重複獲得,m_count累加
      if (mutex->m_count == 0 || mutex->m_owner == self) {
        mutex->m_count++;
        // 標記該互斥鎖已經被本執行緒獲取
        mutex->m_owner = self;
        release(&mutex->m_spinlock);
        return 0;
      }
      break;
    default:
      return EINVAL;
    }
    /* Suspend ourselves, then try again */
    // 獲取失敗,需要阻塞,把當前執行緒插入該互斥鎖的等待佇列
    enqueue(&mutex->m_waiting, self);
    release(&mutex->m_spinlock);
    // 掛起等待喚醒
    suspend(self); /* This is not a cancellation point */
  }
}

   然而我們實際上應用層用到 涉及 syscall 的sleeplock 的效能是不好的,這是因為這裡涉及到 context switch,context switch 不可避免的耗費大量 CPU cycles 去執行 trapframe 儲存恢復和 trap handler 的指令上去,而且自從 Meltdown 的 paper 出來後,Linux 也把原來的共享的 pagetable 換成了 KPTI(isolation),從而 syscall 進入的 kernel space context switch 必須連帶 flush TLB... (Intel 更新了流水線先檢查許可權再 load 可能就不用 KPTI 了,不過 AMD 本來就沒有 Meltdown 漏洞),而 flush TLB 浪費的 cycles 可不能算少了。所以 C++ 11 搞的 atomic 當然效能比 mutex 不知道高到哪裡去,人家那是純粹 runtime 提供的 user space 的 CPU atomic instruction 而已。

    那麼如果沒有了 context switch,是不是就全部用 sleeplock 才是最優解呢?好像的確是這樣的。雖然講的是 O/S, 不妨 Java 其實也是 O/S 吧,Java 裡面有管程(jvm 物件頭)能用來記錄鎖資訊(Thread 睡在哪個鎖上),所以很適合實現 sleeplock,事實上,synchronized 關鍵字),ReentrantLock 都是用這種 sleeplock 方案,會把自己掛起,不過其實 sleep 和 wakeup 這兩件事都是很花時間的,如果鎖被佔用的時間很短,自旋等待的效果就會非常好。反之,如果鎖被佔用的時間很長,那麼自旋的執行緒只會白浪費處理器資源。。。。所以為了通用,Java 搞一個縫合方案:如果自旋超過了限定次數(預設是10次)沒有成功獲得鎖,就應當掛起執行緒(進入睡眠鎖)。


自旋鎖做的讀寫鎖 (什麼是讀者寫者模型?)

    然後我們講到鎖的應用部分, 主要在 kernel 裡面有一個常見的情況, 就是很多 read 和部分 write 的情況, 這個也很好想明白, 單是核心那一堆記憶體裡的 buf、cache 就夠多這種應用場景了. 我們知道有 reader-writer lock, 他主要是基於 spinlock 的, 當然, 這是由於kernel 裡面對記憶體上的 shared data structure 讀寫上並不需要太多的時間, 所以 spinlock 來序列化訪問是解決併發衝突的一個好用的方法了, 我們知道睡眠鎖也是有迴圈的(醒來後鎖又不可用了)不過他適合那種長時間阻塞的共享資源,我們要實現讀者寫者模型裡面讀者和讀者是沒有衝突的,這樣 sleeplock 也會退化為 spinlock(就一個 CAS 而已),所以理論上讀寫之間其實也能做成 sleeplock,讀讀之間則沿用 spin 就行了。讀寫鎖是高效的的與普通鎖相比. 讀寫鎖有三種狀態:讀模式下加速(共享)、寫模式下加鎖(獨佔)以及不加鎖。kernel 的 rwlock 只是封裝了這個狀態限定的 spinlock, 具體實現是用一個欄位而已, 很簡單. 我們需要分析的是他的缺點從而引入今天學習的 RCU.

    給出一種 rwlock 的虛擬碼:

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

    我們可以分析問題, 這是想回想一個 multicore CPU 的實現, 首先可以考慮讀寫併發時必須序列化操作,這一點就是 spinlock 的特色,然後由於多讀者時不用序列化,這就讓讀寫鎖的效能提升的,唯一的序列化僅發生在 CAS 處。


讀寫鎖讀者效能剖析 (為什麼還要改進?)

    那麼我們為什麼要做 RCU 呢?這是因為實際上的讀寫鎖效能並沒有那麼好!! 我們必須 dive deep 看,考慮最壞情況 4 個執行緒都同時請求讀鎖的時候,會發生什麼。下圖為搞笑示意圖。

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

    某個時刻,4個執行緒同時請求執行 CAS 指令(Compare And Swap 本身就是為了保證對訊號量的修改必須是序列的具備一致性的),假如此時 Core 1 CAS 成功,則其他的 cores 都會失敗並回到藍色地方重新讀取訊號量的值。所以理論上我們這裡 T(n) = O(n方),因為每次同時 CAS 後剩下的 cores 都要回去重新讀一遍。

    這有什麼?而且對於併發錯開時間的 CAS 不應該就成功了嗎?不過是區區讀變數?然而我們必須意識到這裡的訊號量是共享的,意味著其由於 CAS 強制序列更新訊號量的時候,CPU 訪存無法享受光速暫存器以及極速的 L1 Cache 待遇,當 Core 1 修改了他時,他就標記為髒而進入 Cache Coherence 的 MESI 協議流程去了,而此流程將消耗許多 CPU cycles,因此這個 O(n方) 是切實的消耗,其實對於 rw 併發也是如此。

    可以體會到, 造成多核讀寫鎖 heavy overhead 的一個重要原因就是哪個 x 的讀入, 即 O/S 課執行緒同步類題目的 semaphore 讀寫那個共享變數導致的迴圈 CAS 判斷開銷. 當然這個理論上最好情況的 O(n方) 已經比原來我們分析 spinlock 裡前置序列所有讀的 busy-wait-loop 純粹的多次浪費的 CAS 好很多了….


RCU 功能特色 (解決什麼問題?)

    所以我們需要搞一些東西來提高效能. 這個叫做 RCU, read + copy update, 他的鎖設計必須是和 data structure 一起合作的. 我們先做思想實驗,上面我們的問題在於,對於訊號量本身是一個共享變數去控制對一個共享資料結構的訪問本身必須序列化一部分操作,並且引發了平方的訪問和 Cache 同步問題。如果我們能改掉這個問題,那麼不僅多讀效能提高,是不是我們順便也可以解決讀寫的問題,甚至支援讀寫併發!回想一開始我們舉例的連結串列,很容易就能想到 copy on write的思路。不過 RCU 沒有這麼暴力的複製一個結構,而是把這個思路運用到區域性中。

    根據 paper, RCU 解決的問題是三個:

  • 支援 concurrent reading 和 updating, 沒有之前 rwlock 的 write 時候強 mutex.
  • deterministic complexity 出於對某些軟體工程上的需求. (我猜想可能是一些實時應用)
  • performance on both computation and storage. 就是小 complexity 咯.

RCU 實現——以 rculist 連結串列 為例 (怎麼做 RCU ?)

 下面給出論文的 RCU 虛擬碼原語.

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

     RCU 實現關鍵點1 結構性更新

    update data not in place, 連結串列例子, 我們不修改內容,而修改結構,如圖這樣分三步並且保留原有結構體的部分就能保證 reader 讀到的資料都是完整的, 沒有 mixture 狀態. 我們對連結串列操作的時候,先建立 copy,然後讓對 copy 的訪問和對原件的訪問具有資料結構上的一致性,最後再更新。我們必須理解(其實這個思路在檔案系統的 log 實現斷電一致性上類似)不同執行緒(或者程式)對於這個連結串列的訪問必定是要麼在步驟 3 完成前,要麼在步驟三完成後,具體來說,就算到了最底層的 RISC 指令上,訪問 l -> next  的時候,next 要麼是已經被修改指向 copy 了,要麼是指向原來的 E2,不會出現野指標的情況。

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

    這個關鍵點導致 RCU 對資料結構有要求, 比如雙向連結串列就用不了 RCU 了. 對樹也不錯. 這個 head 指標的概念在版本控制軟體裡也很常見.

從自旋鎖、睡眠鎖、讀寫鎖到 Linux RCU 機制講解

RCU 實現關鍵點2 記憶體屏障

    記憶體屏障, 我們必須保證上述三部的順序問題. 這一點很重要. 具體的 barrier 放在哪裡我也不是很清楚(視訊這裡本來講的有點模糊, prof Robert 沒有解答為什麼要用 barrier 的一個學生問題,也許是連結串列這個例子不會出現編譯器重排也能跑的優化或者投機執行後錯誤,我具體網路學習後發現這裡其實是涉及到多核的 Cache 問題). 我們瞭解底層是 speculative execution ,out-of-order 的,編譯器重排指令很好禁止。對於 CPU 的話記憶體屏障則涉及 Cache coherence,這裡其實是硬體的妥協,我們知道多核一個 CPU 對變數 Cache 出現讀寫修改後,就告知其他 Core read Invalidate 不可讀的訊號請求清空該 Cache 塊,然後本 Core Cache 寫記憶體,一旦其他 CPU 再讀,就能讀到新的值了。

    然而 CPU 執行運算是光速的,而處理指令和進行資料傳送則慢一點,所以又要搞一個 Store buffer 非同步等待等具體我們不用深究,請看下一句話,那麼問題就在於,如果有這種情況:無效訊號已經確認了大家都沒有涉及更新的 Cache line 了,此時本 Core 要進行寫存更新記憶體,而其他 Core 在寫存瞬間又進行了讀取,又把記憶體載入進 Cache 了,從而導致非一致性問題。具體結果就是,對於多執行緒應用,CPU無能為力,應用程式必須手動新增記憶體屏障。具體內容是涉及 flush store buffer 和 cpu stalling。這裡當然涉及效能的比較,Is it worth it?不過答案好像是是。

 RCU 實現關鍵點3 Commit 提交同步點

    一些讀寫的規則保證能搶佔 commit :1. 讀者不能在讀 RCU 的時候進行 context switch (推動他完成本次讀,而不是沒輕沒重地去幹別的事情)。2. 我們需要提交 update 當且僅當所有的 read 結束, 搶佔進來, free 掉所有的正在被 read 的過氣的歷史資料結構的某個部分, 當然阻塞 updater 也不對, 所以有 callback 版本的 call_rcu .

    具體怎麼樣如判斷 read 結束呢? 論文裡面討論的最簡單的一種方法是通過調整執行緒排程器,使得寫入執行緒簡短的在作業系統的每個CPU核上都執行一下,這個過程中每個CPU核必然完成了一次 context switch。上面的原語中的 run_on 意思就是等一個 context switch 週期. 因為資料讀取者不能在context switch的時候持有資料的引用,所以經過這個過程,資料寫入者可以確保沒有資料讀取者還在持有資料。這部分內容就和 GC 很像了, 由於 GC 語言一般具備追蹤資料是否被使用,實際上我們可以在 GC 語言上實現這樣的 free 操作, 但是 kernel 是否適用 GC 之前 Lecture 中我們又談論過了(教授們完全使用 Golang 實現 Unix Kernel 並對 nginx redis 評估). 當然我們也有 asynchronous 的方案,通過 call_rcu 託管資料結構的 old piece 給 kernel 或者什麼 thread,然後註冊一個 callback freeing function(請記住 RCU 是一種涉及核心態的機制,但他需要自定義資料結構,比如 Linux kernel 可能有個 RCU 連結串列 RCU 數,call_rcu 能統一管理)給 kernel 去完成這個檢測 context switch 的內容。

    具體一些課程的詳細內容複習其他也可以參考大佬翻譯的上課課程內容完全稿.


RCU 效能分析 (什麼時候用 RCU ?)

    RCU 資料讀取非常的快。唯一額外的工作就是在rcu_read_lock和rcu_read_unlock裡面設定好不要觸發context switch,並且在 rcu_dereference中設定 memory barrier,這些可能會消耗幾十個CPU cycle,但是相比鎖來說代價要小的多。

    對於資料寫入者,效能會糟糕一點。首先鎖的獲取鎖和釋放鎖是一個。還有一個可能非常耗時的synchronize_rcu函式呼叫。實際上在synchronize_rcu內部會出讓CPU,所以程式碼在這不會通過消耗CPU來實現等待,但是它可能會消耗大量時間來等待其他所有的CPU核完成context switch。所以基於資料寫入時的多種原因,和資料讀取時的工作量,資料寫入者需要消耗更多的時間完成操作。如果資料讀取區域很短(注,這樣就可以很快可以恢復context switch),並且資料寫入並沒有很多,那麼資料寫入慢一些也沒關係。所以當人們將RCU應用到核心中時,必須要做一些效能測試來確認使用RCU是否能帶來好處,因為這取決於實際的工作負載。Linux 中大量使用了 RCU 資料結構。

 


總結

略(挖個坑)稍後學 Linux 核心原始碼分析的時候再補全 Linux kernel 中完整的 RCU 資料結構分析。

 

相關文章