第四章c++多線程系統編程精要

c++多線程系統編程精要

 

學習多線程系統編程要面臨兩個思維轉變:

1.當前線程可能會被隨時切換出去

2.多線程中事件發生順序不會再有全局的先後關係

當線程被切換回來繼續執行下一條語句的時候,全局數據可能已經被其他線程修改。例如在沒有爲指針p加鎖的情況下,if(p && p->next){/**/}就有可能會 導致segfault,因爲在邏輯與的前一個分支評估爲true的那一剎那,p可能被其他線程設置爲NULL或者被釋放,後一個分支就訪問了一個非法地址

在單cpu系統中,理論上我們可通過cpu執行的指令先後順序來推演多線程的實際交織運行情況。在多核系統中,多個線程是並行執行的,我們甚至沒有一個全局 時鐘來爲每個事件編號。在沒有適當同步的情況下,多個cpu上運行的多個線程中的事件發生順序是無法預測的,在引入了適當的同步之後,事件纔會有先後。

多線程的正確性不依賴於任何線程的執行速度,不能通過原地等待sleep來確定他的事件已經發生,而必須要通過適當的同步來讓當前線程能看到其他線程的執 行結果。無論線程執行的快與慢,程序都應該能正常執行。

 

下面看一下這個demo和例子

bool running = false;

void threadFunc()
{
    while(running)
    {
        //get task from queue
    }
}

void start()
{
    muduo::thread t(threadFunc);
    t.start();
    running = true;
}

這段代碼在系統負載高的時候,running會被推遲賦值,導致系統直接退出,正確做法是把running 放在pthread_create的前面

 

4.1基本的線程原語

POSIX threads 的函數有110多個,真正常用的也就10幾個

11個基本的函數分別是:

2個:線程的創建和等待結束。 4個:mutex的創建 銷燬 加鎖 解鎖 5個:條件變量的創建 銷燬 等待 通知 廣播

用thread mutex 和 condition 可以輕鬆完成多線程任務。

一些函數可以酌情使用:

1.pthread_once 封裝muduo::Singleton。其實不如直接使用全局變量。

2.pthread_key* 封裝爲muduo::ThreadLocal。可以考慮用__thread替換之。

 

讀到這裏我就思考pthread_key是什麼?我記得這個在《《unix網絡編程》》裏有過記載,在這裏在回憶一下數據pthread_key_create,再次回頭看一下unix 網絡編程26章26.5線程特定數據

我們在把一個不可重入帶有靜態變量的函數帶入多線程當中是十分危險的,這個靜態變量無法保存各個線程的值。

使用線程特定數據。這個辦法並不簡單,有點事不需要變動程序調用順序只是需要更改函數中的代碼即可。

使用線程特定數據是讓現有函數變成線程安全函數的有效辦法

不同系統要求支持有限個線程特定數據。posix要求這個限制不小於128個。

pthread_create_key爲我們創建一個不再使用的線程特定數據的key

除了key之外還提供了一個析構函數指針。

我運行了書中的demo

#include <vector>
#include <string>
#include <assert.h>
#include <iostream>
#include <zconf.h>
#include <fcntl.h>

static pthread_key_t r1_key;
static pthread_once_t r1_once = PTHREAD_ONCE_INIT;
#define MAXLINE 1024

typedef struct{
    int r1_cnt;
    char *r1_bufptr;
    char r1_buf[MAXLINE];
}Rline;

static void readline_destructor(void* ptr)
{
    free(ptr);
}

static void readline_once(void)
{
    pthread_key_create(&r1_key,readline_destructor);
}

static ssize_t my_read(Rline *tsd,int fd,char* ptr)
{
    if(tsd->r1_cnt <= 0)
    {
        again:
        if((tsd->r1_cnt = read(fd,tsd->r1_buf,MAXLINE)) < 0)
        {
            if(errno == EINTR)
            {
                goto again;
            }
            return (-1);
        }else if(tsd->r1_cnt == 0)
        {
            return 0;
        }
        tsd->r1_bufptr = tsd->r1_buf;
    }

    tsd->r1_cnt--;
    *ptr = *tsd->r1_bufptr++;
    return(1);
}

size_t readline(int fd,void *vptr,size_t maxlen)
{
    ssize_t n,rc;

    char c, *ptr;

    void *tsd;

    pthread_once(&r1_once,readline_once);

    if((tsd = pthread_getspecific(r1_key)) == nullptr)
    {
        tsd = calloc(1,sizeof(Rline));
        pthread_setspecific(r1_key,tsd);
    }

    ptr = (char*)vptr;

    for(n=1;n<maxlen;n++)
    {
        if((rc = my_read((Rline*)tsd, fd, &c)) == 1)
        {
            *ptr++ = c;

            if(c == '\n')
            {
                break;
            }
        }else if(rc == 0)
        {
            *ptr = 0;
            return (n-1);
        }else{
            return -1;
        }
    }

    *ptr = 0;
    return n;
}
int main()
{
    int fd = open("/home/zhanglei/ourc/test/demoParser.y",O_RDWR);
    if(fd <0)
    {
        return -1;
    }
    char buf[BUFSIZ];
    int res = readline(fd,buf,BUFSIZ);
    if(res <0)
    {
        return -1;
    }
    printf("%d\n",res);
    printf("%s\n",buf);
}

析構函數:

我們的析構函數僅僅釋放造詣分配的內存區域

一次性函數:

我們的一次性函數將由pthread_once調用一次,他只是創建由readline使用的健

Rline結構含有因在圖3-18中聲明爲static而導致前述問題的三個變量。調用readline的每個線程都由readline動態分配一個Rline的結構,然後由析構函數 釋放。

my_read函數

本函數第一個參數現在是指向預先問本線程分配的Rline結構的一個指針。

分配線程特定的數據

我們首先調用pthread_once,使得本進程的第一個調用readlink的線程通過調用pthread_once創建線程的特定健值

獲取特定的數據指針

pthread_getspecific返回指向特定與本線程的Rline結構指針。然而如果這次是本線程首次調用readline,其返回一個空指針。在這種情況下,我們分配一個 Rline結構的空間,並且有calloc將r1_cnt成員初始化爲0.然後我們調用pthread_setspecific爲本線程存儲這個指針。下一次調用readline的時候, pthread_getspecific將返回這個剛剛存儲的指針。

總結

讀到這裏已經瞭解到基本用法了,pthread_once初始化線程特定的key,然後根據特定key獲取線程特定數據,沒有的話重新設置

我們在看下muduo中的代碼,如何把線程特定數據應用到實踐當中

說一些注意點,從上面的代碼我們瞭解到在一些版本中,我們的一個進程最多隻擁有有限量的特定數據,比如一些posix只有128個,書中的ThreadLocal設計 的很簡單,看一下代碼的實現

 

namespace muduo
{

template<typename T>
class ThreadLocal : noncopyable
{
 public:
  ThreadLocal()
  {
    MCHECK(pthread_key_create(&pkey_, &ThreadLocal::destructor));
  }

  ~ThreadLocal()
  {
    MCHECK(pthread_key_delete(pkey_));
  }

  T& value()
  {
    T* perThreadValue = static_cast<T*>(pthread_getspecific(pkey_));
    if (!perThreadValue)
    {
      T* newObj = new T();
      MCHECK(pthread_setspecific(pkey_, newObj));
      perThreadValue = newObj;
    }
    return *perThreadValue;
  }

 private:

  static void destructor(void *x)
  {
    T* obj = static_cast<T*>(x);
    typedef char T_must_be_complete_type[sizeof(T) == 0 ? -1 : 1];
    T_must_be_complete_type dummy; (void) dummy;
    delete obj;
  }

 private:
  pthread_key_t pkey_;
};

}  // namespace muduo

 構造函數中,調用pthread_create_key創建了key以及綁定了析構函數,在析構函數中用來銷燬key,調用的是pthread_key_delete, value函數用來獲取值,destructor用來釋放內存

 

不建議使用的是:

pthread_rwlock,讀寫鎖使用起來要謹慎

sem_* 信號量系列

pthread_cancel 和 pthread_kill 出現他們意味着程序設計出現了問題

我非常推薦這個說法,因爲在<<unix網絡編程第二卷>>中對各個鎖的性能是有比較的,在對一個內存相加的情況下,互斥鎖效率是最高的。 c++多線程編程的難點在於理解庫函數和系統調用的關係

 

4.2 c\c++的安全性

多線程的出現給傳統的編程帶來了衝擊,例如:

1.errno不再是一個全局變量,因爲不同的線程可能執行不同的系統庫函數

2.有些純函數是不受影響的,比如malloc\free、printf和fread、fseek等等。

3.有些使用靜態變量的函數不可能不受到影響,可以使用例如asctime_r,ctime_r,gmtime_r和stderror_r還有stock_r

4.傳統的fork模型不再適合多線程

4.3 linux上的線程標識

POSIX上提供了pthread_self函數用於返回當前的進程標識符,其類型爲pthread_t。pthread_t不一定是一個數值類型,也有可能是一個結構體,因此 pthreads專門提供了pthread_equal函數用於對比兩個線程標識符是否相等

但是這會帶來一些問題,包括:

1.無法打印pthread_t,因爲不知道確切的類型。也就沒法表示他的線程id

2.無法比較pthread_t 的大小或計算他的值,因此無法用做關聯容器的key

3.無法定義一個非法的pthread_t 用來表示線程不存在

4.pthread_t 在進程內有意義,與操作系統的調度無法建立有效的關聯。

另外glibc的pthreads實際上把pthread_t作爲一個結構體指針,而且這個內存塊是很容易被複用

所以說pthread_t不適合作爲線程標識符

在linux上建議使用gettid系統調用作爲線程的id

1.返回的類型是pid_t 便於在日誌中輸出

2.在現代系統中,他表示具體的內核調度的任務id,因此可以很輕鬆的在/proc/tid或者/proc/pid/task/tid下面找到

3.任何時刻都是全局唯一的,並且由於linux分配新的pid採用遞增輪迴的辦法,短時間內啓動多個線程也會覺有不同的線程id

4.0是非法值,因爲操作系統第一個進程init 的 pid是唯一的,當然ubuntu的跟系統是systemd

glibc並沒有提供這個函數,我們要自己寫,我們看一下muduo是如何實現的 核心代碼

#include <sys/syscall.h>
::syscall(SYS_gettid)

當然muduo爲了提升效率是做了緩存的

4.4線程的創建和銷燬

線程的創建和銷燬是基本要素,線程的創建要比銷燬容易的多,只需要遵循下面的幾個原則:

1.程序庫不應該在未提前告知的情況下創建自己的背景線程

2.儘量用相同的方式創建線程

3.進入main函數之前不應該啓動線程

4.程序中線程的創建最好能在初始化階段全部完成。

以下分別談一下這幾個觀點:

一個進程可以創建的併發線程數目受限於地址空間的大小和內核的參數,一臺機器可以並行的線程數目受制於cpu的數目。因此我們在設計線程數目的時候要精心 設計,特別是根據cpu數目來設置線程數目,並且爲關鍵任務留足夠的計算機資源。

另一個原因是如果一個線程中擁有不止一個線程就很難保證fork不出問題

所以我寫程序的時候也是這樣的 fork 之前不會調用pthread_create去創建線程

理想情況下程序的線程都是用同一個class 來創建的,這樣容易在程序的啓動和銷燬階段做一些統一的簿記工作。比如說調用muduo::CurrentThread::tid() 把線程id緩存起來,以後在獲取線程id就不會陷入到內核中去了。也可以統計當前進程有多少個線程在活動,一共創建了多少個線程,每個線程的用途是什麼。 我們可以通過類來給線程命名,也可以用單例建立threadManager來管理當前活動的線程方便調試。

但是這不總是能做到,第三方庫會啓動自己的野生線程,因此他必須要每次都檢查自己的線程id是否有效,而不能假設已經緩存了線程id直接返回就好了。如果 庫提供了異步回調,一定要說明哪些線程調用了用戶提供的異步回調函數這樣用戶就知道能不能執行耗時操作了,會不會阻塞其他的任務執行。

在main函數之前不要啓動線程,因爲這會影響全局對象的安全構造,我們知道c++在進入main函數之前已經完成全局初始化構造。同時各個編譯對象之間的構造 順序是不確定的。無論如何這些全局對象的構造是有序的,都是依次在主函數中完成,並不考慮線程安全問題。但是如果一個全局對象使用了線程,那就危險了 ,因爲這破壞了全局對象的初始化假設,萬一一個線程訪問了未經初始化的全局變量,這種隱晦的錯誤查起來很費勁。如果一個庫要創建線程,那麼要在進入main 函數之後去做這件事

線程的創建數目與cpu有關,不要爲了一個鏈接去創建線程,線程的創建工作最好在初始化階段,這樣代價比頻繁創建和銷燬線程的代價小大約10分之1

線程的銷燬有集中方式:

1.自然死亡 從線程主函數返回,線程正常退出

2.非正常死亡 主函數拋出異常,或者觸發segfault信號

3.自殺 pthread_exit

4.他殺 調用pthread_cancel來強制終止線程

 

線程正常終止的做法只有一個就是自然死亡,任何從外部終止和結束的想法都是錯的,pthread_cancel使線程沒有機會清理資源。也沒有機會釋放一個已經持 有的鎖。

如果確實要考慮終止一個io耗時很長的任務,而又不想週期性的檢查某個全局變量,可以考慮把一部分代碼fork到新的進程,kill(2)一個進程比殺死本進程 中的線程要安全的多,fork的進程與本進程的通訊可以考慮 pipe或者socketpair 或者 tcp

書中有一個重要的原則就是對象的生命週期一般要長於線程的生命週期。

總結 :

這一段內容說的是儘量不要從外部殺死線程,最好做到線程的自然死亡,線程的創建要在main之後,還有的時候我們要考慮第三方庫的野生線程造成的安全問題。
一個重要的原則就是線程的生命週期必須要短於線程的生命週期。

4.4.2 exit(3)不是線程安全的 

exit(3)函數在c++中的作用除了終止,還會析構全局對象和已經構造完成的函數靜態對象。這又潛在的死鎖的可能性,考慮下面的例子

#include <vector>
#include <string>
#include <assert.h>
#include <iostream>
#include <zconf.h>
#include <fcntl.h>
#include <syscall.h>

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

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


class MutexLock :public noncopyable{
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);
    }

    int lock()
    {
        int res = pthread_mutex_lock(&mutex);
        return res;
    }

    void unLock()
    {
        pthread_mutexattr_destroy(&mutexattr);
        pthread_mutex_unlock(&mutex);
    }

    pthread_mutex_t* getMutex()
    {
        return &mutex;
    }
private:
    pthread_mutex_t mutex;
    pthread_mutexattr_t mutexattr;
};

class MutexLockGuard
{
public:
    MutexLockGuard(MutexLock & mutex)
            : _mutex(mutex)
    {
        _mutex.lock();
    }

    ~MutexLockGuard()
    {
        _mutex.unLock();
    }

private:
    MutexLock & _mutex;
};

void someFunctionMayCallExit()
{
    exit(1);
}

class GlobalObject
{
public:
    void doit()
    {
        MutexLockGuard lock(mutex_);
        someFunctionMayCallExit();
    }

    ~GlobalObject()
    {
        printf("GlobalObject:~GlobalObject\n");
        MutexLockGuard lock(mutex_);
        printf("GlobalObject:~GlobalObject cleaning\n");
    }

private:
    MutexLock mutex_;
};

GlobalObject g_obj;

int main()
{
    g_obj.doit();
}

這個例子是非常有意思的一個程序,我們使用這個程序發生了死鎖,命名exit之後在我的認知里程序應該正常退出了,但是它並沒有,而是死鎖了!

我們在這裏思考,爲什麼會死鎖呢?

doit中輾轉調用了exit之後,從而觸發了全局析構函數~GlobalObject(),他試圖對mutex加鎖,然而此時mutex被鎖住了,於是造成了死鎖。

我們再舉一個調用純虛函數導致程序崩潰的例子,假如有一個策略基類,在運行時候我們會根據情況使用不同的無狀態策略。由於策略是無狀態的,因此可以共 享派生類對象,不必每次都去新建。這裏以日曆基類和不同國家的假期爲例子,factory函數返回某個全局對象的引用,而不是每次都創建新的派生類對象。

 

上面的程序當我們在exit時候,析構了全局對象,當我們另一個線程在調用isHoliday的時候會掛掉。

如果一個線程調用了exit,析構了全局對象Date,另一個線程調用isHoliday的時候會出現core dump

可見對現場當中exit不是意見容易的事情,我們需要精心設計析構函數的順序,防止各個線程訪問導致對象失效的問題。

4.5善用__thread關鍵字 

__thread是gcc內部的存儲設施,比pthread_key_t要快。__thread的存儲效率可以和全局變量相比較

int g_var;
__thread int t_var;

void foo()
{
    g_var = 1;
    t_var = 2;
}

__thread 是不能用來修飾class類型的,只能用來修飾POD對象,書中的POD對象指的是

POD全稱Plain Old Data。通俗的講,一個類或結構體通過二進制拷貝後還能保持其數據不變,那麼它就是一個POD類型。

標準佈局的定義
1.所有非靜態成員有相同的訪問權限

2.繼承樹中最多隻能有一個類有非靜態數據成員

3.子類的第一個非靜態成員不可以是基類類型

4.沒有虛函數

5.沒有虛基類

6.所有非靜態成員都符合標準佈局類型

無法調用class的一個重要原因是因爲他無法調用構造函數

 

#include <pthread.h>
#include <cstdio>
#include <cstdlib>
#include <assert.h>
#include <stdint.h>

class A{
public:
    int b;
    A(int data)
    {
        a = data;
    }
private:
    int a;
};

__thread class A a = 3;
int main(int argc, char const *argv[])
{
    a.b = 2;
    return 0;
}

 

下面寫一個demo看一下,__thread修飾的變量是否在各個線程裏有一個獨立的實體

 

#include <pthread.h>
#include <cstdio>
#include <cstdlib>
#include <assert.h>
#include <stdint.h>
#include <unistd.h>

__thread uint64_t pkey = 0;

void* run2( void* arg )
{
    pkey = 8;
    printf("run2-ptr:%p\n",&pkey);
    printf("run2:%ld\n",pkey);
    return NULL;
}

void* run1( void* arg )
{
    printf("run1-ptr:%p\n",&pkey);
    printf("run1:%ld\n",pkey);

    return NULL;
}

int main(int argc, char const *argv[])
{
    pthread_t threads[2];
    pthread_create( &threads[1], NULL, run2, NULL );
    sleep(1);
    pthread_create( &threads[0], NULL, run1, NULL );
    pthread_join( threads[0], NULL );
    pthread_join( threads[1], NULL );
    return 0;
}

 

到這裏我們看到由於加上了__thread 本來第二個線程用該輸出8 結果變成了0,並沒有根據第一個線程的變化而變化

4.6 多線程和IO 

文中說操作文件的io是線程安全的,這個我不是很確定,我一直是用pread和pwrite去處理文件io的,我之前寫過demo,做過實驗,多個線程操作同一個socket 確實是需要上鎖的,如果不上鎖會出現問題的

[email protected]:LeiZhang-Hunter/sendDemo.git

其實這個問題本身意義不大,read和write都是原子的,那麼我們多線程讀寫一個文件的內容,如果要操作,那麼很容易出問題,在不上鎖的情況下,靜態條件 難以避免,時序問題也是一個問題,所以我認爲每個描述符儘量只由一個線程去操作。

4.7用RAII去封裝描述符 

程序剛啓動的時候大家都知道的三個描述符

0 1 2 標準輸入 標準輸出 標準錯誤輸出,posix標準規定每次打開文件的時候描述符必須是當前最小的號碼,其實多線程對於描述符的接口就像我們寫fpm接 口一樣,多線程頻繁的read close同一個描述符必然會出現問題,就像我們一個點贊接口頻繁的 點贊 取消點贊 如果不加鎖,那麼這個接口將會是非常危險的 ,很容易被人刷贊,更糟糕的情況下,頻繁取消點贊都可能變爲負數,這真的是非常糟糕,多線程close 和 read 將會導致描述符串號這一點就不用多說了, 一個線程已經在read,另一個線程close掉了,將會發生很多危險的事情

c++裏採取RAII手法去做這件事,用socket對象去包裝描述符,把關閉放到析構裏面去處理。只要socket還活着,就不會有其他socket對象跟他一樣的描述符。 當然在這裏的對象不要採取 new 這種形式非常危險,可以採用只能指針這將是非常安全的。

我十分贊同書中的思想,儘量少用delete new,儘量採用智能指針。

4.8 RAII與fork

 

在編寫c c++ 的時候,我們總是保證對象的構造和析構函數總是成對出現的,否則一定會有內存泄露,但是加入我們使用fork,將會很糟糕,會破壞這一個假設。

因爲fork之後,子進程會繼承地址空間和空間描述符,因此用於管理動態內存和文件描述符的RAII class 都能正常工作。但是子進程不能繼承

比如說:

1.父進程的內存所,mlock、mlockall

2.父進程的文件鎖 fcntl(2)

3.父進程的某些定時器 settimer alarm timer_create

4.我們可以用 man 2 fork 可以直接查看,有詳細說明

具體不會繼承的內容 分別是

1)進程id

2)內存鎖

3)未決信號集

4)信號量

5)文件鎖

6)timer系列函數

7)父進程未完成的io

4.9 多線程和fork

 書中介紹了fork一般只會克隆控制線程 其他的線程都是會消失的,也就是說不能fork出一個同樣多線程的子進程,稍後會寫一個demo看一下,fork之後只會有 一個線程其他的線程都會小時,這會造成一個十分危險的局面。其他線程正好處在臨界區內,持有了某個鎖,而他突然死亡,再也沒有機會去解鎖了。如果子進程 試圖對mutex加鎖,會立刻造成死鎖。

也就是說,如果主線程內有一個公共的鎖,被其他線程持有了,這時候你fork之後只會保留控制線程,其他的線程佔有了鎖,你fork之後其他線程都沒了,那
這個鎖一旦被使用,那真的是災難性的了,會造成死鎖。

1.malloc,malloc訪問全局變量幾乎都會有鎖(這個我不是很清楚,畢竟沒看過malloc部分的源碼)

2.new、map::insert、snprintf......

3.不能使用pthread_signal去通知父進程,只能通過pipe

4.printf系列函數

5.man7中定義的 signal可重入以外的任何函數

 

到這我自己寫了一個例子用來驗證fork後的線程變化

#include <pthread.h>
#include <cstdio>
#include <cstdlib>
#include <assert.h>
#include <stdint.h>
#include <unistd.h>

__thread uint64_t pkey = 0;

void* run2( void* arg )
{
    while(1)
    {
        printf("%d\n",getpid());
        sleep(2);
    }
    return NULL;
}

void* run1( void* arg )
{

    while(1)
    {
        printf("%d\n",getpid());
        sleep(2);
    }
    return NULL;
}

int main(int argc, char const *argv[])
{
    printf("parent:%d\n",getpid());
    pthread_t threads[2];
    pthread_create( &threads[1], NULL, run2, NULL );
    sleep(1);
    pthread_create( &threads[0], NULL, run1, NULL );
    pid_t pid = fork();
    if(pid > 0)
    {
        pthread_join( threads[0], NULL );
        pthread_join( threads[1], NULL );
    }else{
        printf("son:%d\n",getpid());
        while (1)
        {
            sleep(2);
        }
    }

    return 0;
}

 運行結果:

parent:31424
31424
31424
son:31436
31424
31424
31424
31424
31424
31424

印證了書中的說法,只有控制線程在運行,其他線程沒有運行,注意注意了 這裏是控制線程

4.10 多線程與signal

儘量不要在多線程中用signal

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