該如何做到線程同步---多線程服務器編程的讀書筆記

四大設計原則

1.儘量最低限度的使用共享對象,減少需要同步的場合。一個對象能不暴露給別的線程就不要暴露;如果要暴露,有限考慮immutable對象;實在不行纔可以暴露要修改的對象;實在不行可以修改暴露的對象,並且用同步措施來保護他。

2.其次是使用高級的併發編程構件,如TaskQueue,Producer-Consumer Queue,Count DownLatch;

3.最後不得已必須要使用同步原語的時候,只用互斥器和條件變量,慎用讀寫鎖,不用信號量。

4.不要自己編寫lock free代碼,也不要使用內核級的同步原語。

 

2.1互斥器

互斥器是使用最多的同步原語。單獨使用mutex的時候主要是爲了使用共享數據。我的個人原則是:

使用RAII的手法創建銷燬加鎖解鎖,這四個操作。避免因爲忘記

只是使用非遞歸的mutex。

不手工調用Lock和unlock函數,一切交給棧上的Guard 對象的構造和析構函數負責。Guard的生命週期正好等於臨界區。這樣我們可以保證在同一個scope裏,自動的加解鎖。

在每次構造Guard對象的時候,思考一路上已經持有的鎖,防止因加鎖不同造成的死鎖

次要原則是:

不使用非遞歸的mutex

1)加鎖和解鎖要在同一個線程,線程a不能去unlock線程b已經鎖住的mutex

2)別忘了解鎖

3)不重複解鎖

4)必要的時候使用PTHREAD_MUTEX_ERRORCHECK來排錯

2.1.1只使用非遞歸的mutex

談談我堅持使用非遞歸的互斥器個人想法

mutex分爲遞歸和非遞歸兩種,這是posix的叫法,另外的名字是可重入和非可重入兩種。這兩種區別是同一個線程可以重複對可重入鎖加鎖,但是不能對非遞歸來加鎖。

首選非遞歸的mutex,絕對不是爲了性能,而是爲了體現設計意圖。遞歸和非遞歸其實性能差距不大,因爲少了一個計數器,前者略微快一點,在 同一個線程裏
多次使用非遞歸鎖會導致死鎖,這是一個優點,可以讓我們及早發現不足。

毫無疑問遞歸鎖用起來方便一些,不需要考慮在同一個線程裏會自己把自己給鎖死。

正是因爲他很方便,遞歸鎖可能會隱藏一些問題,你以爲拿到一個鎖可以修改對象了,沒想到外層代碼已經拿到了鎖,正在修改同一個對象呢。

下面讓我們看一下遞歸鎖和非遞歸鎖他們是如何使用的

首先我封裝了一個mutex

class MutexLock{
public:
    MutexLock()
    {
        pthread_mutexattr_init(&mutexattr);
        pthread_mutex_init(&mutex, nullptr);
    }

    MutexLock(int type)
    {
        int res;
        pthread_mutexattr_init(&mutexattr);
        res = pthread_mutexattr_settype(&mutexattr,type);
        pthread_mutex_init(&mutex, &mutexattr);
    }

    ~MutexLock()
    {
        pthread_mutex_destroy(&mutex);
    }

    void lock()
    {
        int res = pthread_mutex_lock(&mutex);
        std::cout<<res<<std::endl;
    }

    void unLock()
    {
        pthread_mutexattr_destroy(&mutexattr);
        pthread_mutex_unlock(&mutex);
    }
private:
    pthread_mutex_t mutex;
    pthread_mutexattr_t mutexattr;
};

在構造函數中傳入type來確定鎖的類型

然後我們看一段demo

MutexLock mutex(PTHREAD_MUTEX_RECURSIVE);


void foo()
{
    mutex.lock();
    // do something
    mutex.unLock();
}

void* func(void* arg)
{
    mutex.lock();
    printf("3333\n");
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, nullptr,func, nullptr);
    foo();
    int res;
    mutex.lock();
    sleep(5);
    mutex.unLock();
    sleep(3);
}

這一段代碼中我們在主線程裏foo 後又mutex.lock之後我們發現程序並沒有死鎖,而是繼續執行,也就是說同一個線程裏遞歸鎖是可以重入的並不會造成死鎖

我們在來測試一下默認的鎖,將申請方式調整爲:

MutexLock mutex(PTHREAD_MUTEX_DEFAULT);

我們會發現上面這段程序死鎖了!!

我在這裏是十分認同陳碩的說法,用非遞歸鎖的優勢是十分明顯的,他非常容易發現錯誤,即使出現了死鎖我們使用gdb去對應的線程裏bt就可以了

當然我們也可以使用c裏面的屬性PTHREAD_MUTEX_ERRORCHECK_NP來檢查錯誤,我們只需要如下聲明鎖

MutexLock mutex(PTHREAD_MUTEX_ERRORCHECK_NP);

然後我們使用下面的程序

int main()
{
    pthread_t tid;
    int res;
    res = mutex.lock();
    printf("%d\n",res);
    res = mutex.lock();
    printf("%d\n",res);
    mutex.unLock();
    printf("end\n");
}

這樣我們出現死鎖的時候,會返回EDEADLK

#define EDEADLK     35  /* Resource deadlock would occur */

所以說由於對於出了問題容易排錯的角度我對書裏面只使用非遞歸鎖的做法比較認可,死鎖也就不在闡述了,定位問題的方法也在上面說過了

2.2條件變量

互斥器是爲了防止計算器資源爭搶,具有排他的特性,但是如果我們希望等待某個條件成立,然後實現解鎖

我們在unix網絡環境編程裏肯定都學過對應的函數

pthread_cond_wait
pthread_cond_signal

csdn地址:https://blog.csdn.net/shichao1470/article/details/89856443
在這裏我還是要再提一次pthread_code_wait的一個點:
“調用者把鎖住的互斥量傳給函數,函數然後自動把調用線程放到等待條件的線程列表上,對互斥量解鎖。這就關閉了條件檢查和線程進入休眠狀態等待
條件改變這兩個操作之間的時間通道,這樣線程就不會錯過條件的任何變化。pthread_cond_wait返回時,互斥量再次被鎖住。”
pthread_cond_wait會字節去解鎖!!

這段話的信息量很大,其中關於互斥量的操作可以理解爲以下三個點:

1.調用pthread_cond_wait前需要先對互斥量mutex上鎖,才能把&mutex傳入pthread_cond_wait函數
2.在pthread_cond_wait函數內部,會首先對傳入的mutex解鎖
3.當等待的條件到來後,pthread_cond_wait函數內部在返回前會去鎖住傳入的mutex

如果需要等待一個條件成立,我們要使用條件變量.條件變量是多個線程或一個線程等待某個條件成立才被喚醒。條件變量的學名也叫管程!

條件變量只有一種使用方式,幾乎不可能用錯。對於wait端:

1.必須和mutex一起使用,這個布爾值必須收到mutex保護

2.mutex已經上鎖的時候才能調用wait

3。把判斷布爾條件和wait放到while循環中

針對上述代碼我門來寫一個例子:

我們可以自己寫一個簡單的Condition 類

class Condition:noncopyable
{
public:
    //explicit用於修飾只有一個參數的構造函數,表明結構體是顯示是的,不是隱式的,與他相對的另一個是implicit,意思是隱式的
    //explicit關鍵字只需用於類內的單參數構造函數前面。由於無參數的構造函數和多參數的構造函數總是顯示調用,這種情況在構造函數前加explicit無意義。
    Condition(MutexLock& mutex) : mutex_(mutex)
    {
        pthread_cond_init(&pcond_, nullptr);
    }

    ~Condition()
    {
        pthread_cond_destroy(&pcond_);
    }

    void wait()
    {
        pthread_cond_wait(&pcond_,mutex_.getMutex());
    }

    void notify()
    {
        (pthread_cond_signal(&pcond_));
    }

    void notifyAll()
    {
        (pthread_cond_broadcast(&pcond_));
    }

private:
    MutexLock& mutex_;
    pthread_cond_t pcond_;
};

這裏說一下noncopyable主要是爲了禁止拷貝,核心是將拷貝構造函數私有化

class noncopyable{
protected:
    noncopyable() = default;
    ~noncopyable() = default;

private:
    noncopyable(const noncopyable&) = delete;
    const noncopyable& operator=( const noncopyable& ) = delete;
};

上面的代碼中必須使用while循環來等待條件變量,而不是使用if語句,原因是是spurious wakeup(虛假喚醒)
這也是面試的考點
對於signal和broadcast端:
1.不一定要在mutex已經上鎖的情況下調用signal(理論上)。
2.在signal之前一般要修飾布爾表達式
3.修改布爾表達式一般要通過mutex保護
4.注意區分signal和broadcast:broadcast通常表明狀態變化,signal表示資源可用

這裏說一下什麼是虛假喚醒,如果我們使用if做判斷

if(條件滿足)
{
    pthread_cond_wait();
}

pthread_cond_wait可能在不調用signal或者broadcast的時候被中斷掉(可能被一個信號中斷或者喚醒),所以我們在這裏一定要使用while

按照書中的例子我們可以十分簡單的寫一個隊列demo

看queue.cc的兩個函數

int dequeue()
{
    mutex.lock();
    while(queue.empty())
    {
        cond.wait();
    }
    int top = queue.front();
    queue.pop_front();
    mutex.unLock();
    return top;
}

void enqueue(int x)
{
    mutex.lock();
    queue.push_back(x);
    cond.notify();
}

在這裏我們可以一起思考一個點cond.notify一定只是喚醒一個線程嗎?

不是的cond.notify可能喚醒一個以上的線程,但是如果我們用了while可以預防虛假喚醒,多個cond_wait被喚醒,內核本質上會對mutex加鎖,所以只會有
一個線程繼續執行,此時再次做while(queue.empty())的判斷,所以依然是線程安全的

條件變量是十分底層的原語,很少直接使用,一般是用來做高級別的同步措施,剛纔我們的例子說了BlockingQueue,下面繼續學習CountDownLatch

CountDownLatch也是同步的常用措施,他主要有兩個用途:

1.主線程發起多個子線程,等待子線程各自完成一定的任務之後,主線程纔會繼續執行。通常用於主線程等待多個子線程初始化完成。

2.主線程發起多個子線程,子線程等待主線程,主線程完成其他的一些任務之後,子線程纔開始繼續執行。通常用於多個子線程等待主線程的起跑命令

下面我們分析一下muduo之中的countDownLatch的實現,一點點分析這些函數的意思

我們先再看一次__attribute__,在沐澤中的代碼是現實

#define THREAD_ANNOTATION_ATTRIBUTE__(x)   __attribute__((x))

我們回顧一下__attribute__(https://blog.csdn.net/qlexcel/article/details/92656797)

attribute__可以設置函數屬性、變量屬性和類屬性,__attribute__使用方法是__attribute((x))

__attribute__可以對結構體共用體進行設置,大致有6個參數可以設定:aligned,packed,transparent_union,unused,deprecated,may_alias

在使用__attribute__的時候,你也可以在參數前後都加上__這連個下劃線,例如使用__aligned__而不是aligned,這樣你就可以在相應的頭文件裏使用它
而不用關心頭文件裏是否有重名的宏定義

1、aligned

指定對象的對齊格式

struct S {

short b[3];

} __attribute__ ((aligned (8)));


typedef int int32_t __attribute__ ((aligned (8)));

這個聲明強制編譯器確保變量類型爲struct S或者int32_t變量在分配空間時候採用8個字節對齊,我們看一下demo結果

struct S {

    short b[3];

};

sizeof之後結果是6

struct S {

    short b[3];

}__attribute__((__aligned__(8)));

這樣書寫完之後結果是8

2)packed
使用這個屬性對struct或union類型進行定義,設定其類型的每一個變量的內存約束。就是告訴編譯器取消結構在編譯過程中的優化對齊,按照實際佔用數進
行對齊,是gcc特有的語法,這個功能跟操作系統沒有關係,跟編譯器有關,gcc編譯器不是緊湊的,windwos下的vc也不是緊湊的,tc編程是緊湊的

我們再看一下這個例子

struct S {

    int a;
    char b;
    short c;


}__attribute__((__packed__));

這個如果去掉packed應該是8個字節,但是__packed__之後編譯器不會進行字節對齊是7個字節

3).at

絕對定位,可以把變量或函數絕對定位到Flash中,或者定位到RAM。這個軟件基本用不到吧,畢竟ram板子片底層了

好到了這裏我要繼續看一下muduo代碼之中一些很細節的使用

我在muduo裏看到很多有意思的代碼,

#ifndef MUDUO_BASE_MUTEX_H
#define MUDUO_BASE_MUTEX_H

#include "muduo/base/CurrentThread.h"
#include "muduo/base/noncopyable.h"
#include <assert.h>
#include <pthread.h>

// Thread safety annotations {
// https://clang.llvm.org/docs/ThreadSafetyAnalysis.html

// Enable thread safety attributes only with clang.
// The attributes can be safely erased when compiling with other compilers.
#if defined(__clang__) && (!defined(SWIG))
#define THREAD_ANNOTATION_ATTRIBUTE__(x)   __attribute__((x))
#else
#define THREAD_ANNOTATION_ATTRIBUTE__(x)   // no-op
#endif

#define CAPABILITY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(capability(x))

#define SCOPED_CAPABILITY \
  THREAD_ANNOTATION_ATTRIBUTE__(scoped_lockable)

#define GUARDED_BY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(guarded_by(x))

#define PT_GUARDED_BY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(pt_guarded_by(x))

#define ACQUIRED_BEFORE(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(acquired_before(__VA_ARGS__))

#define ACQUIRED_AFTER(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(acquired_after(__VA_ARGS__))

#define REQUIRES(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(requires_capability(__VA_ARGS__))

#define REQUIRES_SHARED(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(requires_shared_capability(__VA_ARGS__))

#define ACQUIRE(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(acquire_capability(__VA_ARGS__))

#define ACQUIRE_SHARED(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(acquire_shared_capability(__VA_ARGS__))

#define RELEASE(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(release_capability(__VA_ARGS__))

#define RELEASE_SHARED(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(release_shared_capability(__VA_ARGS__))

#define TRY_ACQUIRE(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(try_acquire_capability(__VA_ARGS__))

#define TRY_ACQUIRE_SHARED(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(try_acquire_shared_capability(__VA_ARGS__))

#define EXCLUDES(...) \
  THREAD_ANNOTATION_ATTRIBUTE__(locks_excluded(__VA_ARGS__))

#define ASSERT_CAPABILITY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(assert_capability(x))

#define ASSERT_SHARED_CAPABILITY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(assert_shared_capability(x))

#define RETURN_CAPABILITY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(lock_returned(x))

#define NO_THREAD_SAFETY_ANALYSIS \
  THREAD_ANNOTATION_ATTRIBUTE__(no_thread_safety_analysis)

這些屬性我是第一次見的,在文檔這裏有具體的解釋看網址:http://clang.llvm.org/docs/ThreadSafetyAnalysis.html

這裏我主要看這段代碼中用到的guarded_by,我們單獨看一下這個宏的定義

#define GUARDED_BY(x) \
  THREAD_ANNOTATION_ATTRIBUTE__(guarded_by(x))

這個宏的意思是首先要對屬性進行鎖定,然後才能進行讀取,回到書中我開始編寫代碼進行實踐,在代碼中我又看到了另一個細節,就是關於這個mutable

好了來看一下CountDownLatch的實現

class CountDownLatch:noncopyable{
public:
    explicit CountDownLatch(int count) : mutex_(),cond_(mutex_),count_(count)
    {
    }
    void wait()
    {
        MutexLockGuard guard(mutex_);
        while(count_ > 0)
        {
            cond_.wait();
        }
    }

    void countDOwn()
    {
        MutexLockGuard guard(mutex_);
        --count_;
        if(count_ == 0)
        {
            cond_.notifyAll();
        }
    }

    int getCount() const
    {
        MutexLockGuard guard(mutex_);
        return count_;
    }

private:
    mutable MutexLock mutex_;
    Condition cond_ __attribute__((guarded_by(mutex_)));
    int count_;
};

mutable字面意思是可變的,容易改變的,mutable也是爲了突破const的限制,我就有一個疑問,就是什麼時候應該使用mutable,c++ 常函數要操作一個屬
性成員的時候要進行修改的話必須要把屬性成員設置爲可變的。

常函數特徵:

1.只能使用數據成員不能修改

2.常對象只能調用常函數,不能調用普通函數

3.常函數的this指針是const *

getCount是一個常函數所以mutex_必須要是一個可變的

下面主要看一下這個類的使用方法

CountDownLatch syncTool(10);

class CThread{
public:
    //線程進程
    static void* threadProc(void* args)
    {
        sleep(4);
        syncTool.countDOwn();
        sleep(3);
    }
};

int main()
{


    int count = 10;
    int i;
    pthread_t tid;
    pthread_t pthread_pool[count];
    CThread threadStack;
    shared_ptr<CThread> threadObj = make_shared<CThread>();
    for(i=0;i<count;i++)
    {
        pthread_create(&tid, nullptr,&CThread::threadProc, nullptr);
    }

    syncTool.wait();

    for(i=0;i<count;i++)
    {
        pthread_join(tid, nullptr);
    }

}

子線程睡醒後會減掉計數器,如果所有子線程初始化完成了,那麼就告訴主線程可以繼續向下進行

sleep並不是一個同步的原語

sleep系列函數只能用來測試,線程中的等待主要是兩種,等待資源可用和等待進入臨界區

如果在正常程序中,如果需要等待一段已知時間,應該對着event loop注入一個timer,然後再timer的回調裏繼續幹活,線程是比較珍貴的資源,不能輕易
浪費,不能用sleep來輪詢

多線程中單例的實現

之前我的代碼中的單例一直是這麼實現的:

T* CSingleton<T, CreationPolicy>::Instance (void)
{
    if (0 == _instance)
    {
        CnetlibCriticalSectionHelper guard(_mutex);

        if (0 == _instance)
        {
            _instance = CreationPolicy<T>::Create ();
        }
    }

    return _instance;
}

這種也是十分經典的實現,我畢業工作到現在單例一直是這麼做的

在多線程服務器編程中,用pthread_once去實現

template <typename T>
class Singleton :noncopyable{
public:
    static T& instance()
    {
        pthread_once(&ponce_,&Singleton::init);
        return *value_;
    }

private:
    Singleton() = default;
    ~Singleton() = default;

    static void init()
    {
        value_ = new T();
    }

    static T* value_;
    static pthread_once_t ponce_;
};

利用內核的api去實現,確實也是一個十分不錯的注意。

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