linux內核分析與應用 -- 第2章 併發(上)

 

 

很多程序員在面試的時候經常會被問到線程安全相關的問題,比如什麼是線程安全,什麼又是線程不安全,假如線程不安全,如何解決才能做到線程安全。這時候,往往會出現五花八門的答案,而且大多數都是本末倒置。很多時候,人們經常會用一些現象來回答問題,比如房價高這個問題,很多時候大家就會歸結於某些現象:溫州炒房團、丈母孃經濟、對比國際大城市房價等。但是,我們需要的是“原理性的解釋”,比如影響房價的經濟學原理如供需關係、不均衡分佈等。

再回歸到線程安全問題,這是一個非常經典的問題,需要搞懂併發原理,才能搞清楚線程安全。任何事物的發展,都是有因果關係的,就像霍金博士一生孜孜不倦地研究,無非也就是想搞懂人類從哪裏來,站在何方,將要去向哪裏等大問題。所以針對併發這樣的話題,我們學習的思路應該是這樣的:

  • 併發到底是什麼,如何在系統中產生。

  • 併發會帶來什麼問題。

  • 如何解決併發帶來的問題。

我覺得這個思考方式,應該可以用於大部分技術原理的學習和研究了。只有帶着正確的問題出發,纔有可能得到你想要的答案。下面我們就根據以上3個問題對併發相關的話題進行探討,在後續的章節中,我還會反覆強調這樣的思考方式。

本章先介紹併發原理,再分析 Linux 中的併發相關工具,最後介紹開源軟件中的併發問題是如何解決的。

2.1 什麼是併發

首先我們需要搞清楚到底什麼是併發,它在系統中又是以何種形式存在的。

2.1.1 併發是如何產生的

在操作系統中,一個時間段中有幾個程序都處於已啓動運行到運行完畢之間,且這幾個程序都是在同一個處理器上運行,這種情形叫併發。但是,在任一個時刻只有一個程序在處理器上運行。

從這個過程中我們大致可以瞭解到,併發主要和處理器(CPU)有關,當同時有多個運行中的程序需要佔用處理器資源,就形成了併發。圖2-1總結了併發的兩種場景,第一種場景是多個進程使用同一個處理器內核資源,第二種場景是多個進程使用不同的處理器內核資源。

圖2-1 兩種併發場景

2.1.2 併發會帶來什麼問題

針對上面介紹的併發兩種場景,會有不同的問題。我們先來分析第一種場景,多個進程同時使用同一個處理器核(core)資源。我們知道一個處理器核在同一時刻只能被一個進程佔用,那麼,從微觀角度講真正的併發應該不存在,應該不會有任何問題纔對呀?很遺憾,事實情況並非如此,爲了防止 CPU 資源被同一個進程長期佔用,大部分硬件都會提供時鐘中斷機制,在中斷髮生的時候,會進行進程的切換,當前進程會讓出 CPU,並且讓其他進程能獲得 CPU 的機會。因爲進程切換的存在,假如共享同一個內存變量,就會存在代碼臨界區,比如 i++ 操作,就不能保證原子性,如圖2-2所示。因爲 i++ 其實分爲兩個步驟:

圖2-2 多個進程同時使用同一個處理器核的情況

1)add i

2)set i

假設 i=0,當進程1執行完 add i 後,就發生了切換。進程2重新開始執行 add i,那麼2個進程都執行完 i++ 之後,結果 i 的值還是1。

所以,在這種情況下,併發帶來的問題就是進程切換造成的代碼臨界區。

我們來分析併發的第二種場景,多個進程同時使用多個 CPU 核。在這種情況下,會引發兩種問題。第一種問題和多個進程使用1個 CPU 核引發的問題一樣,由於先天就是多個核並行執行多個進程的程序,假如共享同一個變量操作,必然會存在代碼臨界區。

圖2-3 多個進程同時使用多個處理器核的情況

第二種問題如圖2-3所示,我們可以發現,因爲 CPU 每個核都維護了一個 L2 cache(二級緩存),其目的是爲了減少與內存之間的交互,提升數據的訪問速度。但是這樣,就會造成主存中的數據複製存在多份在各自的 L2 cache 中,導致數據不一致。這就是 CPU 二級緩存和內存之間的可見性問題。

2.1.3 如何解決併發帶來的問題

上節分析了併發帶來的問題,歸根結底就2類:

  • 代碼臨界區的問題。

  • 主存可見性的問題。

下面我們分別來介紹這兩類問題的解決方案。

先說代碼臨界區問題。孫子曰:“百戰百勝,非善之善者也;不戰而屈人之兵,善之善者也。”也就是說最好的戰爭方式,就是不要發動戰爭,通過謀略讓對手投降。殺敵一千,自損八百,很是划不來。所以,處理代碼臨界區的問題也是一樣,最好的方式就是消除臨界區。很多時候,臨界區是由於自己考慮不周到,代碼編寫方式不正確造成的,只要設計得當,是有可能消除的。

不過凡事無絕對,假如不能消除臨界區,那麼我們只能硬着頭皮想辦法對付了。前面我們分析臨界區出現問題是因爲多個進程同時進入了臨界區,造成了邏輯的混亂。所以,我們可以把臨界區作爲一個整體,讓多個進程串行通過臨界區,達到保護臨界區的目的。這樣的機制我們就叫做同步。同步在技術上一般都是通過鎖機制來解決的,後面我們會具體分析 Linux 中的不同鎖實現方式。

另外像 i++ 這樣的操作,一般都會在硬件級別提供原子操作指令作爲解決方案,本章我們也會介紹原子變量的實現方法,一般都會通過 cmpxgl 這樣原子指令來支持。

接着來看主存可見性的問題。多個進程依賴同一個內存變量,那麼爲了保證可見性,可以通過讓 L2 cache 強制失效,都去主存中取數據。有時候編譯器爲了提升程序執行效率,都會對編譯後的代碼進行優化,讓某些指令在上下文中的結果依賴 L2 cache,我們可以通過內存屏障等方式,去除編譯器優化,本章後面會具體介紹這種方法。

2.2 操作系統會在哪些場景遇到併發

在互聯網時代來臨之前,內核雖然生來就被設計成支持多用戶的,但是很少面臨高併發請求考驗,多用戶的操作很多時候都是人工來進行的,人敲鍵盤的速度再快也很難達到秒級的。所以,最開始,併發僅僅針對內核級別,給內核加了一把大內核鎖(BKL)。一旦某個用戶在使用內核,其他用戶則無法獲取內核資源。

但是大內核鎖太粗暴了,粒度太大。在互聯網應用場景就吃不消了。互聯網時代,針對不同的細節場景,開發了不同的內核工具來解決相應的問題。圖2-4介紹了 Linux 內核不同併發場景提供的工具實現。

圖2-4 Linux 內核針對不同併發場景的工具實現

我把操作系統和併發相關的場景歸爲4類:

1)和 CPU 相關的原子變量(Atomic)和自旋鎖(Spin_lock)。

在併發訪問的時候,我們需要保證對變量操作的原子性,通過 Atomic 變量解決該問題。其實自旋鎖的使用場景和互斥鎖類似,都是爲了保護臨界區資源,但是自旋鎖是在 CPU 上進行的忙等,所以暫時就把它和原子變量歸爲一類了。

2)圍繞代碼臨界區控制的相關工具有:信號量(Semaphore)、互斥(Mutex)、讀寫鎖(Rw-lock)、搶佔(Preempt)。

有時候要對多個線程進行精細化控制,就要用到信號量了,下面引用百度百科中的例子:

以一個停車場的運作爲例。簡單起見,假設停車場只有三個車位,一開始三個車位都是空的。這時如果同時來了五輛車,看門人允許其中三輛直接進入,然後放下車攔,剩下的車則必須在入口等待,此後來的車也都不得不在入口處等待。這時,有一輛車離開停車場,看門人得知後,打開車攔,放入外面的一輛進去,如果又離開兩輛車,則又可以放入兩輛車,如此往復。在這個停車場系統中,車位是公共資源,每輛車好比一個線程,看門人就是起到了信號量的作用。

互斥從某種角度來講,可以理解爲池子大小爲1的信號量,它和信號量的原理類似,都會讓無法獲取資源的線程睡眠。

很多時候併發的訪問往往都是讀大於寫,爲了提高該場景的性能,內核提供了讀寫鎖進行優化訪問控制。

3)從 CPU 緩存角度,爲優化多核本地訪問的性能,內核提供了 per-cpu 變量。

在多核場景,爲了解決併發訪問內存的問題,經常需要鎖住總線,這樣效率很低。很多時候併發的最好方案就是沒有併發,per-cpu 變量的設計正是基於這樣的思路。

4)從內存角度,爲提升多核同時訪問內存的效率提供了 RCU 機制,另外,爲了解決內存訪問有序性問題,提供了內存屏障(memory barrier)。假如需要多核同時寫同一共享數據,要保證不出問題,我能想到的也就是 Copy On Write 這樣的思路,RCU 機制就是基於這個思路的實現。

程序在運行時內存實際的訪問順序和程序代碼編寫的訪問順序不一定一致,這就是內存亂序訪問。內存亂序訪問行爲出現的理由是爲了提升程序運行時的性能。在併發場景下,這種亂序就具有不確定性,內存屏障就是用來消除這種不確定性,保證併發場景的可靠性。

2.3 Linux 中併發工具的實現

通過上一節的介紹,我們大概瞭解了內核中的併發場景,以及 Linux 提供的相應工具,本節把這些工具的實現簡單分析一下。

2.3.1 原子變量

原子變量是在併發場景經常使用的工具,很多併發工具都是基於原子變量來實現的,比如自旋鎖。原子變量對其進行的讀寫操作都必須保證原子性,也就是原子操作。

1.什麼是原子操作

對於 i++ 這樣的操作,如果要在雙核的 CPU 上每核都執行這條指令,假如現在 i=1,那麼執行完之後,你希望第一個核執行完之後 i 被設置爲2,第二個核執行完之後 i 被設置爲3。但是,由於 i++ 這樣的執行不是原子操作,所以2個核有可能同時取到 i 的值爲1,然後加完之後 i 最終爲2。

這種問題是典型的“讀-修改-寫”場景,避免該場景引發不一致問題就是確保這樣的操作在芯片級是原子的。

x86 在多核環境下,多核競爭數據總線的時候,提供了 Lock 指令來進行鎖總線的操作,在《Intel 開發者手冊》卷 3A,8.1.2.2中說明了 Lock 指令可以影響的指令集:

1)位測試和修改的指令(BTS、BTR 和 BTC)。

2)交換指令(XADD、CMPXCHG 和 CMPXCHG8B)。

3)Lock 前綴會自動加在 XCHG 指令前。

4)單操作數邏輯運算指令:INC、DEC、NOT 和 NEG。

5)雙操作數的邏輯運算指令:ADD、ADC、SUB、SBB、AND、OR 和 XOR。

2.原子變量(atomic)的實現

定義如下:

typedef struct {
    int counter;
} atomic_t;

add 和 sub 方法:

static __always_inline void atomic_add(int i, atomic_t *v)
{
    asm volatile(LOCK_PREFIX "addl %1,%0"
                 : "+m" (v->counter)
                 : "ir" (i));
}

static __always_inline void atomic_sub(int i, atomic_t *v)
{
    asm volatile(LOCK_PREFIX "subl %1,%0"
                 : "+m" (v->counter)
                 : "ir" (i));
}

通過之前分析我們知道 intel 的原子指令保證操作的原子性。並且多核環境下使用 lock 來鎖總線,保證串行訪問總線。

讀取方法爲:

static __always_inline int atomic_read(const atomic_t *v)
{
    return READ_ONCE((v)->counter);
}

在讀的時候爲了防止髒讀,READ_ONCE 中加上了 volatile 去除編譯器優化。

2.3.2 自旋鎖

1.爲什麼使用自旋鎖

由於自旋鎖(Spin_lock)只是將當前線程不停地執行循環體,而不改變線程的運行狀態,所以響應速度更快。但當線程數不斷增加時,性能下降明顯,因爲每個線程都需要執行,佔用 CPU 時間。所以它保護的臨界區必須小,且操作過程必須短。很多時候內核資源只鎖毫秒級別的時間片段,因此等待自旋鎖的釋放不會消耗太多 CPU 的時間。

2.自旋鎖的實現

自旋鎖其實是通過一個屬性標誌來控制訪問鎖的請求是否能滿足,我們先來看一下 spinlock 的定義:

typedef struct spinlock {
    union {
        struct raw_spinlock rlock;

#ifdef CONFIG_DEBUG_LOCK_ALLOC
# define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
        struct {
            u8 __padding[LOCK_PADSIZE];
            struct lockdep_map dep_map;
        };
#endif
    };
} spinlock_t;

去除 debug 的干擾,我們可以看到 spinlock 的核心成員爲:

struct raw_spinlock rlock

接着看 raw_spinlock 的結構:

typedef struct raw_spinlock {
    arch_spinlock_t raw_lock;
#ifdef CONFIG_GENERIC_LOCKBREAK
    unsigned int break_lock;
#endif
#ifdef CONFIG_DEBUG_SPINLOCK
    unsigned int magic, owner_cpu;
    void *owner;
#endif
#ifdef CONFIG_DEBUG_LOCK_ALLOC
    struct lockdep_map dep_map;
#endif
} raw_spinlock_t;

可以看到 raw_spinlock 最終依賴與體系結構相關的 arch_spinlock_t 結構,我們以 x86 爲例,該結構如下所示:

typedef struct arch_spinlock {
    union {
        __ticketpair_t head_tail;
        struct __raw_tickets {
            __ticket_t head, tail;
        } tickets;
    };
} arch_spinlock_t;

其中 _ticketpair_t 爲16位整數,_ticket_t 爲8位整數。

通過 spin_lock_init 宏可以初始化自旋鎖,init 的過程可以理解爲把 head_tail 的值設置爲1,並且爲未鎖住的狀態。

下面是獲取鎖的過程:

static __always_inline int arch_spin_trylock(arch_spinlock_t *lock)
{
    arch_spinlock_t old, new;

    old.tickets = READ_ONCE(lock->tickets);
    if (!__tickets_equal(old.tickets.head, old.tickets.tail))
        return 0;

    new.head_tail = old.head_tail + (TICKET_LOCK_INC << TICKET_SHIFT);//  tail+1
    new.head_tail &= ~TICKET_SLOWPATH_FLAG;

    // cmpxchg 是一個完全內存屏障(full barrier)
    return cmpxchg(&lock->head_tail, old.head_tail, new.head_tail) == old.head_tail;
}

其中:

static inline int  __tickets_equal(__ticket_t one, __ticket_t two)
{
    return !((one ^ two) & ~TICKET_SLOWPATH_FLAG);
}

_tickets_equal 的過程 one 和 two 先做異或,假如兩者一樣則返回0,TICKET_SLOW-PATH_FLAG 爲0,取反後則變爲 OXFF,那麼該函數表明假如 one 和 two 相等則返回真;否則返回假。

arch_spin_trylock 的過程爲:

1)校驗鎖的 head 和 tail 是否相等,假如不相等,則獲取鎖失敗,返回0。

2)給 tail+1。

3)比較 lock->head_tail 和 old.head_tail 的值是否相等,如果相等,則把 new.head_tail 賦給 new.head_tail 並且返回1。

接着我們來看釋放鎖的過程:

static __always_inline void arch_spin_unlock(arch_spinlock_t *lock)
{
    if (TICKET_SLOWPATH_FLAG &&
        static_key_false(&paravirt_ticketlocks_enabled)) {
        __ticket_t head;
        BUILD_BUG_ON(((__ticket_t)NR_CPUS) != NR_CPUS);
        head = xadd(&lock->tickets.head, TICKET_LOCK_INC);

        if (unlikely(head & TICKET_SLOWPATH_FLAG)) {
            head &= ~TICKET_SLOWPATH_FLAG;
            __ticket_unlock_kick(lock, (head + TICKET_LOCK_INC));
        }
    } else
        __add(&lock->tickets.head, TICKET_LOCK_INC, UNLOCK_LOCK_PREFIX);
}

這個函數的關鍵就在於:

__add(&lock->tickets.head, TICKET_LOCK_INC, UNLOCK_LOCK_PREFIX);

解鎖的過程就是給 __add&lock->tickets.head 做+1操作。

接下來看判斷是否上鎖的條件:

static inline int arch_spin_is_locked(arch_spinlock_t *lock)
{
    struct __raw_tickets tmp = READ_ONCE(lock->tickets);
    return !__tickets_equal(tmp.tail, tmp.head);
}

從上面的函數我們可以知道,其實就是判斷 tail 和 head 是否相等,假如不相等則說明已經上鎖了。

最後我們來看一下循環等待獲取鎖的過程:

static __always_inline void arch_spin_lock(arch_spinlock_t *lock)
{
    register struct __raw_tickets inc = { .tail = TICKET_LOCK_INC };
    inc = xadd(&lock->tickets, inc);
    if (likely(inc.head == inc.tail))
        goto out;

    for (;;) {
        unsigned count = SPIN_THRESHOLD;
        do {
            inc.head = READ_ONCE(lock->tickets.head);
            if (__tickets_equal(inc.head, inc.tail))
                goto clear_slowpath;
            cpu_relax();
        } while (--count);
        __ticket_lock_spinning(lock, inc.tail);
    }
clear_slowpath:
    __ticket_check_and_clear_slowpath(lock, inc.head);
out:
    barrier();
}

這個過程步驟如下:

1)tail++。

2)假如 tail++ 之前 tail 和 head 相等,則說明現在已經獲得了鎖,退出。

3)假如 tail 和 head 不相等,則循環等待,直到相等爲止。

圖2-5 獲取和釋放自旋鎖的過程

圖2-5說明了整個加鎖和釋放鎖的過程,每次上鎖都會進行 tail++。解鎖進行 head++,當 head==tail 的時候,則說明未上鎖。

2.3.3 信號量

通過前面的介紹,我們已經知道信號量(Sema-phore)用於保護有限數量的臨界資源,在操作完共享資源後,需釋放信號量,以便另外的進程來獲得資源。獲得和釋放應該成對出現。從操作系統的理論角度講,信號量實現了一個加鎖原語,即讓等待者睡眠,直到等待的資源變爲空閒。

下面我們來分析信號量的實現,其定義如下:

struct semaphore {
    raw_spinlock_t      lock;                        //  獲取計數器的自旋鎖
    unsigned int        count;                       // 計數器
    struct list_head    wait_list;                   // 等待隊列
};

圖2-6描述了信號量獲取和釋放的原理,即 down 和 up 的過程。在 down 的過程中,假如 count>0,則做 count- 操作;否則執行 __down,並且在獲取自旋鎖的時候保存中斷到 eflags 寄存器,最後再恢復中斷。

圖2-6 信號量獲取和釋放的原理圖

其中 __down 的執行過程爲:

1)先把當前 task 的 waiter 放入 wait_list 隊列尾部。

2)進入死循環中。

3)假如 task 狀態滿足 signal_pending_state,則跳出循環,並且從等待隊列中刪除,返回 EINTR 異常。

4)假如等待的超時時間用完了,則跳出循環,並且從等待隊列中刪除,返回 ETIME 異常。

5)設置 task 狀態爲之前傳入的 TASK_UNINTERRUPTIBLE(該狀態只能被 wake_up 喚醒)。

6)釋放 sem 上的 lock。

7)調用 schedule_timeout,直到 timout 後被喚醒,然後重新申請 sem->lock。

8)假如 waiter.up 狀態變爲 true 了,則說明到了被 up 喚醒的狀態了,則返回0。

在 up 的過程中,先獲取 sem->lock,並且保存中斷。如果 sem->wait_list 爲空,則直接做 sem->count++ 操作;否則執行 __up。

其中 __up 的執行過程爲:

1)從 sem->wait_list 隊列中找到第一個等待的任務。

2)從等待隊列中刪除該任務。

3)把 waiter->up 設置爲 true。

4)嘗試喚醒該進程。

2.3.4 互斥鎖

互斥鎖(Mutex)從功能上來講和自旋鎖類似,都是爲了控制同一時刻只能有一個線程進入臨界區。從實現上來講,自旋鎖是在 CPU 上實現忙等,而互斥鎖則會讓無法進入臨界區的線程休眠。從某種角度來講,互斥鎖其實就是退化版的信號量。下面是互斥鎖的定義:

struct mutex {
    //  1: unlocked, 0: locked, 小於0: locked, 在鎖上有等待者
    atomic_t                count;
    spinlock_t              wait_lock;
    struct list_head        wait_list;
…
};

可以發現 count 只有兩種狀態1和0,1爲 unlock;0爲 locked。其他實現都和信號量類似,大家可以結合代碼並且參考信號量的實現來自己分析。

2.3.5 讀寫鎖

在很多時候,併發訪問都是讀大於寫的場景,假如把讀者當做寫者一樣加鎖,那麼對性能影響較大。所以讀寫鎖(rw-lock)分別對讀者和寫者進行了處理,來優化解決該場景下的性能問題。

下面我們來看 Linux 對讀寫鎖的實現,首先來看一下在 x86 中對其的定義:

typedef struct qrwlock {
    atomic_t            cnts;
    arch_spinlock_t     wait_lock;
} arch_rwlock_t;

其中原子變量 cnts 初始化爲0,自旋鎖 wait_lock 初始化爲未上鎖狀態。

結合圖2-7我們來分析其實現原理:

圖2-7 讀寫鎖實現原理

獲取讀鎖的過程如下:

1)如果 cnts 低八位的讀部分爲0,那麼就進入下一步;否則獲得鎖失敗。

2)對高位的讀爲+1。

3)再進行判斷是否寫位置爲0,如果是則返回1,說明獲得了鎖。

4)如果寫鎖被別人獲得了,那麼就把高位減1,並且返回0,獲得讀鎖失敗。

釋放讀鎖的過程只要把 cnts 的高位減1即可。

獲取寫鎖的過程如下:

1)假如 cnts 爲0,則 if 條件不滿足,說明沒有讀者和寫者;否則要是存在讀者或者寫者,返回0,獲取寫鎖失敗。

2)把 cnts 的低八位寫標誌設置爲 OXFF。

釋放寫鎖則直接把低八位的讀標誌設置爲0。

2.3.6 搶佔

我們先回顧一下,一個進程什麼時候會放棄 CPU:

  • 時間片用完後調用 schedule 函數。

  • 由於 IO 等原因自己主動調用 schedule。

  • 其他情況,當前進程被其他進程替換的時候。

那麼,加入搶佔(preempt)的概念後,當前進程就有可能被替換,假如當你按下鍵盤的時候,鍵盤中斷程序運行之後會讓進程 B 替換你當前的工作進程 A,原因是 B 進程優先級比較高,這就是搶佔。

內核要完成搶佔必然需要打開本地中斷,這兩者是不可分割的,如圖2-8所示。

圖2-8 用戶鍵盤輸入發生搶佔

下面我們來看 Linux 中開啓搶佔的實現:

#define preempt_enable() \
do { \
    barrier(); \
    if (unlikely(preempt_count_dec_and_test())) \
        __preempt_schedule(); \
} while (0)

假如 _preempt_count-1 之後還是大於0,那麼將會執行:_preempt_schedule();

asmlinkage __visible void __sched notrace preempt_schedule(void)
{

    if (likely(!preemptible()))
        return;

    preempt_schedule_common();
}


#define preemptible()(preempt_count() == 0 && !irqs_disabled())

static void __sched notrace preempt_schedule_common(void)
{
    do {
        preempt_disable_notrace();
        __schedule(true);
        preempt_enable_no_resched_notrace();
    } while (need_resched());
}

preempt_schedule 函數檢查是否允許本地中斷,以及當前進程的 preempt_count 字段是否爲0,如果兩個條件都爲真,它就調用 schedule()選擇另外一個進程來運行。因此,內核搶佔可能在結束內核控制路徑(通常是一箇中斷處理程序)時發生,也可能在異常處理程序調用 preempt_enable()重新允許內核搶佔時發生。

2.3.7 per-cpu 變量

目前生產環境的服務器大多數跑的都是 SMP(對稱多處理器結構),如圖2-9所示。因爲 SMP 系統多個核心與內存交互的時候,因爲 L1 cache 的存在,會出現一致性的問題。所以,最好的方式就是每個核自己維護一份變量,自己用自己的,這樣就不會出現一致性問題了。

圖2-9 獨立 L1 cache 的 SMP 處理器架構

爲了解決這個問題,Linux 引入了 per-cpu 變量,可以在編譯時聲明,也可以在系統運行時動態生成。

首先來感受一下 per-cpu 變量的使用方法。per-cpu 變量在使用之前需要先進行定義,編譯期間創建一個 per-cpu 變量:

DEFINE_PER_CPU(int,my_percpu);                         // 聲明一個變量
DEFINE_PER_CPU(int[3],my_percpu_array);                // 聲明一個數組

使用編譯時生成的 per-cpu 變量:

ptr = get_cpu_var(my_percpu);
// 使用ptr
put_cpu_var(my_percpu);

也可以使用下列宏來訪問特定 CPU 上的 per-cpu 變量:

per_cpu(my_percpu, cpu_id);

per-cpu 變量導出,供模塊使用:

EXPORT_PER_CPU_SYMBOL(per_cpu_var);
EXPORT_PER_CPU_SYMBOL_GPL(per_cpu_var);

動態分配 per-cpu 變量:

void *alloc_percpu(type);
void *__alloc_percpu(size_t size, size_t align);

使用動態生成的 per-cpu 變量:

int cpu;
cpu = get_cpu();
ptr = per_cpu_ptr(my_percpu);
// 使用 ptr
put_cpu();

接下來我們通過 per-cpu 變量的初始化過程來分析其實現原理,系統在啓動時會調用 _init setup_per_cpu_areas 爲 per-cpu 變量申請內存空間:

void __init setup_per_cpu_areas(void)
{
    unsigned int cpu;
    unsigned long delta;
    int rc;
…
#ifdef CONFIG_X86_64
        atom_size = PMD_SIZE;
#else
        atom_size = PAGE_SIZE;
#endif
        rc = pcpu_embed_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
                    dyn_size, atom_size,
                    pcpu_cpu_distance,
                    pcpu_fc_alloc, pcpu_fc_free);
        …
}
if (rc < 0)
    rc = pcpu_page_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
            pcpu_fc_alloc, pcpu_fc_free,
            pcpup_populate_pte);
…
/* percpu 區域已初始化並且可以使用 */
delta = (unsigned long)pcpu_base_addr - (unsigned long)__per_cpu_start;
for_each_possible_cpu(cpu) {
    per_cpu_offset(cpu) = delta + pcpu_unit_offsets[cpu];
    per_cpu(this_cpu_off, cpu) = per_cpu_offset(cpu);
    per_cpu(cpu_number, cpu) = cpu;
    setup_percpu_segment(cpu);
    setup_stack_canary_segment(cpu);
    // 下面進行 early init 階段需要初始化的 per_cpu 數據
#ifdef CONFIG_X86_LOCAL_APIC
        per_cpu(x86_cpu_to_apicid, cpu) =
            early_per_cpu_map(x86_cpu_to_apicid, cpu);
        per_cpu(x86_bios_cpu_apicid, cpu) =
            early_per_cpu_map(x86_bios_cpu_apicid, cpu);
#endif
#ifdef CONFIG_X86_32
        per_cpu(x86_cpu_to_logical_apicid, cpu) =
            early_per_cpu_map(x86_cpu_to_logical_apicid, cpu);
#endif
#ifdef CONFIG_X86_64
        per_cpu(irq_stack_ptr, cpu) =
            per_cpu(irq_stack_union.irq_stack, cpu) +
            IRQ_STACK_SIZE - 64;
#endif
#ifdef CONFIG_NUMA
        per_cpu(x86_cpu_to_node_map, cpu) =
            early_per_cpu_map(x86_cpu_to_node_map, cpu);
…
}

其中兩個關鍵步驟爲:

1)pcpu_page_first_chunk。先分配一塊 bootmem 區間 p,作爲一級指針,然後爲每個 CPU 分配 n 個頁,依次把指針存放在 p 中。p[0]..p[n-1]屬於 cpu0,p[n]-p[2n-1]屬於 CPU2,依次類推。接着建立一個長度爲 n×NR_CPUS 的虛擬空間(vmalloc_early),並把虛擬空間對應的物理頁框設置爲 p 數組指向的 pages。然後把每 CPU 變量 _per_cpu_load 拷貝至每個 CPU 自己的虛擬地址空間中。

2)將 .data.percpu 中的數據拷貝到其中,每個 CPU 各有一份。由於數據從 _per_cpu_start 處轉移到各 CPU 自己的專有數據區中了,因此存取其中的變量就不能再用原先的值了,比如存取 per_cpu_runqueues 就不能再用 per_cpu_runqueues 了,需要做一個偏移量的調整,即需要加上各 CPU 自己的專有數據區首地址相對於 _per_cpu_start 的偏移量。在這裏也就是 _per_cpu_offset[i],其中 CPU i 的專有數據區相對於 _per_cpu_start 的偏移量爲 _per_cpu_offset[i]。

經過這樣的處理,.data.percpu 這個 section 在系統初始化後就可以釋放了。

其中 _per_cpu_load 被重定向到了 .data..percpu 區域,和 _per_cpu_start 位置是一樣的:

#define PERCPU_SECTION(cacheline)
    . = ALIGN(PAGE_SIZE);
    .data..percpu        : AT(ADDR(.data..percpu) - LOAD_OFFSET) {
        VMLINUX_SYMBOL(__per_cpu_load) = .;
        PERCPU_INPUT(cacheline)
    }

圖2-10爲 per-cpu 變量的初始化流程,我們可以發現,經過 setup_per_cpu_areas 函數,per_cpu 變量被拷貝到了各自的虛擬地址空間。原來的 per_cpu 變量區域,即 _per_cpu_start 和 _per_cpu_end 區域將會被刪除。

圖2-10 per-cpu 變量的初始化

2.3.8 RCU 機制

在 Linux 中,RCU(Read,Copy,Update)機制用於解決多個 CPU 同時讀寫共享數據的場景。它允許多個 CPU 同時進行寫操作,而且不使用鎖,其思想類似於 copy on write 的原理,並且實現垃圾回收器來回收舊數據。

使用 RCU 機制有幾個前提條件:

  • RCU 使用在讀者多而寫者少的情況。RCU 和讀寫鎖相似。但 RCU 的讀者佔鎖沒有任何的系統開銷。寫者與寫者之間必須要保持同步,且寫者必須要等它之前的讀者全部都退出之後才能釋放之前的資源。

  • RCU 保護的是指針。這一點尤其重要,因爲指針賦值是一條單指令,即一個原子操作,因此更改指針指向沒必要考慮它的同步,只需要考慮 cache 的影響。

  • 讀者是可以嵌套的,也就是說 rcu_read_lock()可以嵌套調用。

  • 讀者在持有 rcu_read_lock()的時候,不能發生進程上下文切換;否則,因爲寫者需要要等待讀者完成,寫者進程也會一直被阻塞。

下面是 whatisRCU.txt 中使用 RCU 鎖的例子:

struct foo {
    int a;
    char b;
    long c;
};
DEFINE_SPINLOCK(foo_mutex);

struct foo *gbl_foo;
void foo_update_a(int new_a)
{
    struct foo *new_fp;
    struct foo *old_fp;

    new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);
    spin_lock(&foo_mutex);
    old_fp = gbl_foo;
    *new_fp = *old_fp;
    new_fp->a = new_a;
    rcu_assign_pointer(gbl_foo, new_fp);
    spin_unlock(&foo_mutex);
    synchronize_rcu();
    kfree(old_fp);
}

int foo_get_a(void)
{
    int retval;

    rcu_read_lock();
    retval = rcu_dereference(gbl_foo)->a;
    rcu_read_unlock();
    return retval;
}

如上代碼中,RCU 用於保護全局指針 struct foo*gbl_foo.foo_get_a()用來從 RCU 保護的結構中取得 gbl_foo 的值。而 foo_update_a()用來更新被 RCU 保護的 gbl_foo 的值。

我們再思考一下,爲什麼要在 foo_update_a()中使用自旋鎖 foo_mutex 呢?

假設中間沒有使用自旋鎖。那 foo_update_a()的代碼如下:

void foo_update_a(int new_a)
{
    struct foo *new_fp;
    struct foo *old_fp;

    new_fp = kmalloc(sizeof(*new_fp), GFP_KERNEL);

    old_fp = gbl_foo;
    1:-------------------------
    *new_fp = *old_fp;
    new_fp->a = new_a;
    rcu_assign_pointer(gbl_foo, new_fp);

    synchronize_rcu();
    kfree(old_fp);
}

假設 A 進程在上面代碼的----標識處被 B 進程搶點,B 進程也執行了 goo_ipdate_a(),等 B 執行完後,再切換回 A 進程,此時,A 進程所持的 old_fd 實際上已經被 B 進程給釋放掉了,此後 A 進程對 old_fd 的操作都是非法的。

RCU API 說明

我們在上面也看到了幾個有關 RCU 的核心 API,它們爲別是:

rcu_read_lock()
rcu_read_unlock()
synchronize_rcu()
rcu_assign_pointer()
rcu_dereference()

其中:

  • rcu_read_lock()和 rcu_read_unlock()用來保持一個讀者的 RCU 臨界區,在該臨界區內不允許發生上下文切換。

  • rcu_dereference():讀者調用它來獲得一個被 RCU 保護的指針。

  • rcu_assign_pointer():寫者使用該函數來爲被 RCU 保護的指針分配一個新的值,這樣是爲了安全地從寫者到讀者更改其值,這個函數會返回一個新值。

  • rcu_dereference:實現也很簡單,因爲它們本身都是原子操作,因爲只是爲了 cache 一致性,插上了內存屏障,可以讓其他的讀者/寫者看到保護指針的最新值。

  • synchronize_rcu:函數由寫者來調用,它將阻塞寫者,直到所有讀執行單元完成對臨界區的訪問後,寫者纔可以繼續下一步操作。如果有多個 RCU 寫者調用該函數,它們將在所有讀執行單元完成對臨界區的訪問後全部被喚醒。

結合圖2-11我們來說明 Linux RCU 機制的思路:

1)對於讀操作,可以直接對共享資源進行訪問,但前提是需要 CPU 支持訪存操作的原子化,現代 CPU 對這一點都做了保證。但是 RCU 的讀操作上下文是不可搶佔的,所以讀訪問共享資源時可以採用 read_rcu_lock(),該函數的功能是停止搶佔。

2)對於寫操作,思路類似於 copy on write,需要將原來的老數據做一次拷貝,然後對其進行修改,之後再用新數據更新老數據,這時採用了 rcu_assign_pointer()宏,在該函數中首先通過內存屏障,然後修改老數據。這個操作完成之後,老數據需要回收,操作線程向系統註冊回收方法,等待回收。這個思路可以實現讀者與寫者之間的併發操作,但是不能解決多個寫者之間的同步,所以當存在多個寫者時,需要通過鎖機制對其進行互斥,也就是在同一時刻只能存在一個寫者。

3)在 RCU 機制中存在一個垃圾回收的後臺進程,用於回收老數據。回收時間點就是在更新之前的所有讀者全部退出時。由此可見,寫者在更新之後是需要睡眠等待的,需要等待讀者完成操作,如果在這個時刻讀者被搶佔或者睡眠,那麼很可能會導致系統死鎖。因爲此時寫者在等待讀者,讀者被搶佔或者睡眠,如果正在運行的線程需要訪問讀者和寫者已經佔用的資源,那麼將導致死鎖。

圖2-11 Linux RCU 機制實現原理

那究竟怎麼去判斷當前的寫者已經操作完了呢?我們在之前看到,讀者在調用 rcu_read_lock 的時候會禁止搶佔,因此只需要判斷所有的 CPU 都進過了一次上下文切換,就說明所有讀者已經退出了。[1]

2.3.9 內存屏障

程序在運行過程中,對內存訪問不一定按照代碼編寫的順序來進行。這是因爲有兩種情況存在:

  • 編譯器對代碼進行優化。

  • 多 CPU 架構存在指令亂序訪問內存的可能。

爲解決這兩個問題,分別需要通過不同的內存屏障來避免內存亂序。

首先我們來看第一種情況,編譯器優化。例如有如下場景:

線程1執行:

while (!condition);
print(x);

線程2執行:

x = 100;
condition = 1;

condition 初始值爲0,結果線程1打印出來不一定爲100,因爲編譯器優化後,有可能線程2先執行了 condition=1;後執行 x=100;我們可以在 gcc 編譯的時候加上 O2 或者 O3 的選項,就會發生編譯器優化。

爲了消除該場景下編譯器優化帶來的不確定性,可以使用內存屏障:

#define barrier() __asm__ __volatile__("" ::: "memory")
x = 100;
barrier()
condition = 1;

另外,可以給變量加上 volatile 來去除編譯器優化:

#define ACCESS_ONCE(x) (*(volatile typeof(x) *)&(x))
ACCESS_ONCE(x) = 100;
ACCESS_ONCE(condition) = 1;

接着我們來看多 CPU 運行當中內存訪問亂序的問題,圖2-12是 Intel CPU 的 P6 微架構,目前大部分的 Inter CPU 都沿用了該架構的思路,其他都是一些小的優化。從圖中可以看到,CPU 在處理指令的時候,爲了提升性能,減少等待內存中的數據,採用了亂序執行引擎。

注意 

很多時候我們並不能保證代碼是按照我們書寫的順序來運行的。

假設如下代碼:

volatile int x, y, r1, r2;
void start()
{
    x = y = r1 = r2 = 0;
}
void end()
{
    assert(!(r1 == 0 && r2 == 0));
}
void run1()
{
    x = 1;
    r1 = y;
}
void run2()
{
    y = 1;
    r2 = x;
}

圖2-12 Intel CPU 的 P6 微架構

代碼執行順序爲:

1)start()

2)線程1執行 run1()

3)線程2執行 run2()

4)調用 end()

結果 r1 或者 r2 均有可能爲0,原因就是亂序執行引擎的存在。要解決這個問題,在 Pentium 4微處理器中引入了彙編語言指令 lfence、sfence 和 mfence,它們分別有效地實現讀內存 barrier、寫內存 barrier 和“讀-寫”內存 barrier:

#define mb()     asm volatile("mfence":::"memory")
#define rmb()    asm volatile("lfence":::"memory")
#define wmb()    asm volatile("sfence" ::: "memory")

可以這樣修改:

void run1()
{
x = 1;
mb();
    r1 = y;
}
void run2()
{
y = 1;
mb();
    r2 = x;
}

[1] 參考 http:// www.ibm.com/developerworks/cn/linux/l-rcu/ 中對 RCU 過程有詳細描述。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章