该如何做到线程同步---多线程服务器编程的读书笔记

四大设计原则

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去实现,确实也是一个十分不错的注意。

猜你喜欢

转载自blog.csdn.net/qq_32783703/article/details/105896010