C++服务器开发精髓笔记

C++服务器开发精髓

第一章 必知必会

1.1 RAII

先分配资源,再操作,任意一步出错需要回收资源。

避免冗余代码方式:

  1. goto语句(不推荐)
  2. do...while(0)循环(现有代码中大量存在)
  3. RAII(推荐)

在构造函数中申请资源,在析构中释放。对于多线程中锁的获取与释放,可充分利用器特性,避免每次返回都需要释放锁,避免冗余代码。c++11中可用std::lock_guard。

熟练使用RAII能让代码更简洁,也能有效的避免内存泄漏和死锁问题。

1.2 pimpl

Point to Implementation,保持对外接口不变,又不暴露成员变量和私有函数。

优点:

  1. 核心数据被隐藏,对使用者透明,提高安全性。
  2. 接口与实现分离。整个类的对外声明不变,但是其内部声明可以改变。增删改Impl的成员和方法,保持其引用类的头文件不变。
  3. 降低编译依赖。头文件被转移到cpp文件中,精简头文件,对于其他需要该头文件的文件来说,可以加快编译速度。

c++11引入了智能指针对象,可以使用std::unique_ptr对象来管理上述用于隐藏具体实现的指针。

#include<memory>
class A
{
    A();
    ~A();
private:
    struct Impl;
    std::unique_ptr<Impl>	m_pImpl;
};

//c++11
A::A()
{
 	m_pImpl.reset(new Impl());   
}
//c++14
A::A():m_pImpl(std::make_unique<Impl>())
{
    
}
/*用了智能指针来管理内存后就不需要再在析构函数中显示的释放内存了*/

1.3 新特性

c++11是c++发展史上的重大更新,改进了c++98/03的众多问题并引入了很多新的语言特性。

  1. 废弃了不实用的语法和库,std::auto_ptr 新增了很多关键字和其他语法final,=default,=delete.
  2. 从语言本身增加对操作系统功能的支持,不需要再写大量与平台相关的代码。(线程库,时间库)

1.4 统一的类成员初始化语法与std::initialzer_list<T>

假设类A有一个类型为int数组的成员变量,在c++98/03中在构造函数初始化中我们要这样写。对于字符数组可能就要使用strcpy或者memcpy了。

class A
{
public:
    A()
    {
        //当数组是局部变量时可以批量初始化 a = {2,4};但是在构造函数中却不行。
    	a[0] = 2;
        a[1] = 4;
    }
public:
    int a[2];
};

//新语法中也能进行批量初始化了
A::A():a{2,4}
{
    
}

c++11新引入了对象std::initialize_list<T>,用来实现可接受多个自定义类型的{}语法,接受自定义类型T,使用需要包含头文件#include<initialze_list>

class A
{
    A(std::initialize_list<int> integers)
    {
        m_vectMeng.insert(m_vectMeng.begin(),integers.begin(),integers.end());
    }
public:
    vector<int> m_vectMeng;
};

void main()
{
    A a{1,2,3,4};
}

initialize_list<T> 还提供了三个成员函数
size_type size() const;
const T* begin() const;
const T* end() const;

1.5 c++17注解标签(attributes)

c++98/03中,不同编译器使用不同的注解为代码添加额外的说明。c++11起同意制定了常用注解标签。

[[attribute]] types/functions/enums/etc

c++98/03中,枚举时不限定作用域,即作用域中不能再出现与枚举中相同的变量名。

enum Color
{
    black,
    white
};
//c++98/03中报错
bool black = true;

//c++11中的限定作用域的枚举
enum class Color
{
    black,
    white
};
bool white = true;//编译成功
  • [[noreturn]] void terminate(); 告诉编译器函数没有返回值,一般在调用系统函数时使用
  • [[deprecated]] void Fun(); 告诉使用者该函数已经被弃用,有的编译器会生成警告,有的直接报错。[[ deprecated("using FunX instead") ]] void Fun();给出具体的警告信息。
  • [[faltthrough]] case语句中,case后没有break编译器会给出警告,如果是有意为之,可用此消除编译器的警告。
  • [[nodiscard]] 不能忽略函数的返回值,如果忽略会给出警告。
  • [[maybe_unused]] 对于一些声明的但是没有使用的语句,编译器会给出警告,使用它可以消除这个警告。(好像没卵用啊。。。。)

1.6 final,override,=default,=delete

  1. final:用于修饰一个类,写在类名后,表明这个类不能够再被继承。

  2. override:父类加了virtual的方法能被子类重写,子类加不加virtual关键字都行。这会带来两个弊端

    1. 不能直观的看出是不是重写的父类的方法。
    2. 子类不小心写错了需要重写的函数签名(参数,返回值),改方法会变成一个独立的方法,而编译器不会发现。

    加了override关键字编译器会检查改方法是重写的父类的方法,如果函数签名错误在编译期间会给出相应的错误。

  3. =default:如果没有显示的给出构造函数、析构函数、拷贝构造和操作符重载=,在使用时编译器会自动生成默认的无参函数或者在链接时报错,用=default标记的函数编译器会给出默认实现。通常用来简化构造函数中没有实际初始化代码的写法------特别是在.h和.cpp文件分离时。

  4. =delete:禁止编译器自动生成构造函数、析构函数、拷贝构造函数和操作符重载函数。实际工程中如果明确不需要这4个函数,为了防止编译器自动生成,直接用=delete禁止即可,还能减小可执行文件的体积。

1.7 auto关键字

自动推导数据类型。一般用来推导复杂模板的数据类型,减少代码长度。

1.8 Range-based

//c++11 for循环新写法
int arr[10] = {0};
for (auto i:arr)
{
    std::cout<< i <<std::endl;
}

for-each陷阱:

  1. for-each中的迭代器类型与数组或者集合中元素的类型一致。使用传统的iterator时,iter是容器中元素的指针类型。
  2. for-each中迭代器是复杂类型的拷贝,而不是原始数据的引用。(也就是说它有局部的生命周期,跟普通的变量使用时没有差别?)

1.9 结构化绑定

std::pair一般只能表示两个元素,std::tuple可以放任意数量的元素,不需要一个被定义成结构体的POD。

std::tuple<int, string, char, double, int> userinfo(26,"mengziyue",'A',213.45,1995);
int age 		= std::get<0>(userinfo);
string name 	= std::get<1>(userinfo);
char level 		= std::get<2>(userinfo);
double salary 	= std::get<3>(userinfo);
int  birthYear 	= std::get<4>(userinfo);
//怎么感觉还是结构体简单些啊。。。。难以维护,不能见名知意。

//结构化绑定的语法
auto [a,b,c, ...]  = experssion;
auto [a,b,c, ...]  = { experssion };
auto [a,b,c, ...]  = ( experssion );

double arr[3] = {2.3, 1, 5.6};
auto [num1,num2,num3] = arr;

struct Point
{
    double x;
    double y;
}
Point point1(1, 2.3);
const auto& [width,heigth] = point1;

同样注意,这里的绑定名称是绑定目标的一份拷贝,请用&或者const &来避免不必要的拷贝。

限制:

  1. 不能使用constexpr或者声明为static。(建议使用auto它不香吗)
  2. 有些编译器不支持在Lamda表达式捕获列表中使用结构化绑定的语法。

1.10 stl容器新增的实用方法

1.10.1原位构造与容器的emplace系列函数

在循环中对一个临时对象进行存储,存储的是拷贝构造函数生成的新对象。使用emplace_back()可以用来替代原先容器的的push_back()操作,减少临时对象的构造、复制构造和析构的开销,只需要构造函数即可。这就是“原位构造元素(EmplaceConstructible)”。

原方法 c++11改进方法 含义
push/insert emplace 在容器指定位置原位构造元素
push_front emplace_front 在容器首部原位构造元素
push_back emplace_back 在容器尾部原位构造元素

10.1.2 std::map的try_emplace和insert_or_assign

std::try_emplace,存在则使用,不存在则创建。

class A
{
    
public:
    void active();
}
std::map<UInt64,A*> mA;

//原始方案,也是现在项目上一直在用的。
void Click(UInt64 uuid)
{
    std::map<UInt64,A*>::iterator iter= mA.find(uuid);
    if (iter != mA.end())//找到了
    {
        iter->second->active();
    }
    else
    {
        A* pA = new A();
        mA.insert(std::pair<UInt64,A*>(uuid,pA));
        pA->active();
    }
}

//c++17的try_emplace:检测key是否存在,如果存在则不做任何事情。
void Click(UInt64 uuid)
{
    auto [iter,bInserted] = mA.try_emplace(uuid);
    if (bInserted)
    {
        //std::map<UInt64,A*> mA; 因为map的value是指针,try_emplace的第二个参数是支持构造一个对象,当uuid不存在而被成功插入时,会导致相应的value是空指针,这就是多进行下面一步操作的原因。
        iter->second = new A();
    }
    
    iter->second->active();
}

//用shared_ptr重构
void Click(UInt64 uuid)
{
    auto spA = std::make_unique<A>();
    auto [iter,bInserted] = mA.try_emplace(uuid,std::move(spA));
    iter->second->active();
}
/*
以上函数中,构造和析构都被多调用。这是因为无论uuid是否在map上,都会创建类A,而这个类A是用不上的,导致做了无用功。
*/

//减少构造和析构的次数
void Click(UInt64 uuid)
{
    auto [iter ,bInsert] = mA.try_emplace(uuid);
    if (bInsert)
    {
        //按需创建对象
        auto spA = std::make_unique<A>();
        iter->second = std::move(spA);
    }
    
    iter->second->active();
}

/*
在auto [iter ,bInsert] = mA.try_emplace(uuid)中,
第二个值是bool类型,表示操作是否成功。如果成功,返回的iter中含有数据。
PS:在容器中应该存储指针或者智能指针。
*/

std::insert_or_assign,存在则更新,不存在则插入。

std::map<std::string,int> mapAgeTable{{"mengziyue",26},{"futiantian",26}};
//map中不存在,创建
mapAgeTable.insert_or_assign("mengqingyi",26);
//map中已经存在,更新
mapAgeTable.insert_or_assign("futiantian",18);

1.11 stl的智能指针类详解

c++11废弃了std::auto_ptr,取而代之的是std::unique_ptr.

1.11.1 std::auto_ptr

std::auto_ptr<int> ap1(new int(66));

std::auto_ptr<int> ap2(ap1);	//拷贝构造,堆对象被转移给ap2
std::auto_ptr<int> ap3 = ap2;	//复制构造,堆对象被转移给ap3
//在遍历时经常会有类似赋值传递等操作,这样很容易造成空指针,可能遇到意想不到的错误

1.11.2 std::unique_ptr

/*
	std::unique_ptr:对堆内存具有唯一的持有权,该智能指针对资源的引用计数永远是1。对象销毁时会释放该堆内存。鉴于std::auto_ptr的前车之鉴,std::unique_ptr禁用了复制语义,拷贝构造和复制构造均被标记为=delete。默认情况下智能指针对象析构时会释放其指向的堆内存,但是如果有对应的其他资源要回收(套接字句柄,文件句柄等),可通过给智能指针自定义资源回收函数来释放这些资源。

*/
std::unique_ptr<int> sp1 (new int(123));
std::unique_ptr<int> sp3 = std::make_unique<int>(123);	//推荐

//通过移动构造函数转移堆内存对象
std::unique_ptr<int> sp1(std::make_unique<int>(345));
std::unique_ptr<int> sp2(std::move(sp1));
std::unique_ptr<int> sp3 = std::move(sp2);

//自定义资源回收函数的语法规则
std::unique_ptr<T,DeletorFun>
auto deletor = [](Socket* psocket)
{
    psocket->close();
    //甚至可以打印日志
    delete psocket;
}

std::unique_ptr<T,void(*)(Socket* psocket)> spDelete(new Socket(),deletor);
std::unique_ptr<T,decltype(deletor)> spDelete(new Socket(),deletor);

1.11.3 std::shared_ptr

/*	std::unique_ptr对其持有的资源具有独占性,而std::shared_ptr持有的资源可以在各个share_ptr之间共享,每多一个引用,资源的引用计数就会加一,对象析构时会减一,到0时将释放所有资源。*/class A{    }std::shared_ptr<A> sp1(new A());std::cout<<sp1.use_count()<<std::endl;	//sp1引用计数是1std::shared_ptr<A> sp2 = std::make_shared<A>(sp1);	//sp1引用计数是2sp2.reset();	//sp1的引用计数变为1

1.11.4 std::enable_shared_from_this

/*	std::enable_shared_from_this,返回包裹当前对象的std::shared_ptr对象给外部使用,有需要的类需要继承自std::enable_shared_from_this<T>模板对象即可。*/class A:public std::enable_shared_from_this<A>{    std::shared_ptr<A> getSelf(){        return shared_from_this();    }}//使用时需要注意以下事项1.不应该共享栈对象的this指针给智能指针对象A a;std::shared_ptr<A> spA = a.getSelf(); //在此处崩溃智能指针管理的是堆对象,栈对象在函数返回时自行销毁,因此不能通过该队现交由智能指针对象管理。智能指针的目的就是用来管理堆对象    2.循环引用class A:public std::enable_shared_from_this<A>{    std::shared_ptr<A> getSelf(){        return shared_from_this();    }        void func()    {        m_spSelfPtr = shared_from_this();    }public:    std::shared_ptr<A> m_spSelfPtr;}int main(){    {        std::shared_ptr<A> spa(new A);        spa->func();    }}//一个资源的生命周期可以交给智能指针管理,但是该智能指针的生命周期不可以再交给该资源来管理。

1.11.5 std::weak_ptr

/*
	std::weak_ptr是不控制资源生命周期的智能指针,是对对象的一种弱引用,只提供了最其管理资源的一个访问手段,不能直接操作对象。引入他的目的是协助std::shared_ptr工作。
	两个std::shared_ptr相互引用会导致死锁问题,既引用永不为0,资源永远不释放。std::weak_ptr的构造和析构不会改变引用计数,因此可以用来解决引用死锁的问题。std::weak_ptr可以从std::shared_ptr或者另一个std::weak_ptr构造。可以通过std::weak_ptr的lock函数获得std::shared_ptr
	既然std::weak_ptr不管理引用资源的生命周期,改引用资源就可能在某个时刻失效,在需要引用该资源时,需要用expired方法来检测,如果返回true说明资源已经失效。
*/
if (spa.expired())
{
    return;
}
//多线程编程时,这里仍然可能有隐患,可能在此spq持有的对象正好被释放。
std::shared_ptr<A> spa2 = std::spa.lock();

if (spa2)
{
    ...
}
//std::weak_ptr常被用于订阅者模式或者观察者模式。消息发布器只有在某个订阅者存在的情况下才会向其发布消息,不能管理订阅者的声明周期。

1.12 智能指针的对象大小及注意事项

std::unique_ptr指针的大小是原始指针大小。std::shared_ptr和std::weak_ptr的大小是原始指针大小的两倍。

//1.一旦使用了智能指针管理对象,就不应该使用原始裸指针去操作它。A* a = new A();std::shared_ptr<A> spa(a);A* nakePtr = spa.get();	//nakePtr和a指向同一个对象//2.知道在那种场合使用哪种智能指针需要智能指针管理资源的声明周期时,当资源不需要在其他地方共享,优先考虑std::unique_ptr,反之使用std::shared_ptr。如果不需要管理资源的生命周期,则使用std::weak_ptr。//3。避免操作某个引用资源已经释放的智能指针。std::shared_ptr<A> spa(new A());auto& spa2 = spa;	//注意是引用spa.reset();		//spa2也被释放了spa2->doSomething();	//spa2不再持有对象,行为不确定。//4.作为类成员变量,应该优先使用前置声明。为了减少编译依赖、加快编译速度和减少二进制文件的大小,一般采用前置声明的方式而不是直接包含对应类的头文件。

第二章 工具和调试

2.1 gdb调试

1.在实际调试时,通常关闭优化,用来保证调试时的行号能和代码完全匹配。

2.对debug版的二进制文件使用strip命令,可去除调试信息,同时减小程序体积,提高运行效率。

有如下三种方法进行调试:

  1. gdb filename:直接调试
  2. gdb attach pid:调试正在运行的程序。附加目标进程时调试器会暂停,使用continue会让程序继续运行。
  3. gdb filename corename:利用core文件进行调试

2.2 gdb常用命令详解

1.p this:打印当前对象的地址。p *this,打印对象的各个成员变量的值。p func()打印func的执行结果。

2.p serv.port=5452 p命令还能修改变量的值。

3.ptype。输出变量类型

4.info thread:查看线程信息。

2.3 gdb实用技巧

#打印超长字符串或者字符数组
set print element 0

#让被调试的程序接收信号
	1.singnal SIGINT
	2.handle SIGINT nostop print

#函数存在,添加断点却无效。根据文件名和行号添加断点

#数据断点:数据被改变时才触发的断点,用watch观察
#条件断点:break [LineNo] if [condition]


第三章 多线程编程和资源同步

3.1线程的基本操作

void func()
{
    
}
//传统方式创建
#include<pthread.h>
int pthread_create(pthread_t* thread,				//通过该参数获取线程ID
                   const pthread_attr* attr,		//线程属性
                   void*(*start_routine) (void*),	//线程函数
                   void* args)						//线程函数所需的参数
返回值:
    成功:0
    失败:返回错误码,常见的错误码有EAGAIN、EINVAL、EPERM。
    
pthread_t tid;
pthread_create(&tid,NULL,func,NULL);

//std::thread创建
#include<thread>
std::thread t1(func);		//t1可以当作一个对象,注意其生命周期,出了作用域将会被销毁


//获取线程id
pthread_t pthread_self(void);

//pstack:查看进程的线程数量和每个线程的调用堆栈情况.必须有调试符号-g和对应权限。
top -H 和pstack pid搭配能够排查程序运行情况。
    

int pthread_create()

c++11提供的std::thread类对线程函数签名没有特殊要求,但是linux的线程函数签名必须是指定格式。如果使用c++面向对象的方式对线程函数进行封装,线程函数就不能是类的实例方法(普通成员函数)了,必须是类的静态方法。

class A
{
    void* threadFunc(void* arg);	//类的实例方法
};
//无论是实例方法还是静态方法,编译器在编译时会将其“翻译”成全局函数,即去掉类的域限制。对于类的实例方法,为了保证类方法的功能正常,翻译时会将类的实例对象地址(也就是this指针最为第一个参数传给)类实例方法。翻译后该实例方法变成了如下模样:
void* threadFunc(A* this,void* arg);
//而此时线程函数的签名要求是void* threadFunc(void* arg);因此,线程函数不能是类的实例方法。但是又有个问题,类的静态成员方法不能访问类的非静态成员方法
//用c++11的std::thread类就没有限制。
class A
{
public:
    void Start(){ m_spThread.reset(new std::thread(&A::ThreadFunc,this,1995,2021)); };
private:
    int ThreadFunc(int a, int b);
    //使用智能指针包裹对象,无须手动释放对象了
    std::shared_ptr<std::thread> m_spThread;
};

//为了解决创建线程时不能使用类的实例方法,可在创建线程时将对象的地址(this指针)传递给线程函数,然后在线程函数中将该指针转化为原来的类实例,再通过这个实例就可以访问所有的类方法了。
//在线程函数中调用pthread_create创建线程时,将当前对象的this指针作为线程函数的唯一参数传入,在线程函数中就可以通过线程函数的参数得到对象的指针了通过这个指针能够*访问类的实例方法。
 ::pthread_create(&m_pid,NULL,&ThreadFunc,this);	//ThreadFunc是类的静态方法

//通过std::bind给线程函数绑定this指针,可以达到绑定类实例方法的目的。
class A
{
public:
    void RecvThread();
	std::unique_ptr<std::thread> m_spRecvThread;
}
void A::Start()
{
    m_spRecvThread.reset(new std::thread(std::bind(&A::RecvThread,this)));
}

静态成员函数只能访问静态成员

类的成员函数是在编译期间编译器会默认在其第一个参数添加一个this指针,这个指针指向对象的地址,正因为如此,在类对象调用该类的方法时能够正常工作,这也同时要求必须在获取对象后才能调用类的实例方法。

类的静态成员函数为所有类共用,它在静态存储区,没有this指针,不需要实例就能访问其它静态成员,同样正是因为没有this指针,无法知道是那个对象需要调用改方法,因此静态成员函数只能访问静态成员。

类的静态成员函数

3.2 整型变量的原子操作

线程同步:多个线程同时操作某个资源导致冲突,引发意料之外的结果。

3.2.1 整型的变量赋值为啥不是原子的?

  1. 赋确定的值:这一般是原子的,立即寻址,一条汇编指令即可搞定。
  2. 自增/自减:不是原子的。1.变量搬到寄存器 2.寄存器自增/自减 3.寄存器搬回到内存。
  3. 两个变量间赋值:数据不能直接从内存搬运,要借助寄存器,因此这个也有两步。

c++11对整型变量的原子操作的支持

借助模板类std::atomic对常见的基本类型进行原子操作。

std::atomic<int> value = 99;			//linux上无法编译通过,std::atomic禁止了拷贝构造函数

std::atomic<int> value;
value = 99;								//这里重载了operator=

3.3 Linux线程同步对象

3.3.1 Linux互斥体

通过限制多个线程同时访问一个某段代码。可以使用定义与pthread.h中的pthread_mutex_t表示一个互斥体对象。

//1.初始化
pthread_mutex_t myMutex = PTHREAD_MUTEX_INITIALIZER;
//若互斥体是动态分配的或者需要设置属性
int pthread_mutex_init(pthread_mutex_t* restrict mutex,
                       const pthread_mutexattr_t restrict attr);

pthread_mutex_t myMutex;
pthread_mutex_init(&myMutex,NULL);

//2.销毁
int pthread_mutex_destory(pthread_mutex_t* mutex);
//注意:1.无需销毁使用PTHREAD_MUTEX_INITIALIZER初始话的互斥体
	   2.不小销毁一个已经加锁或者被条件变量使用的互斥体对象,此时销毁会返回EBUSY错误。
       
//3.操作
int pthread_mutex_lock(pthread_mutex_t* mutex);
int pthread_mutex_trylock(pthread_mutex_t* mutex);
int pthread_mutex_unlock(pthread_mutex_t* mutex);

//属性
1.PTHREAD_MUTEX_NORMAL
    若一个线程对普通锁加了锁,其他线程会阻塞在这里,直到加锁的线程释放了锁。
    一个线程若对普通锁再加锁,程序会阻塞在第二次加锁的地方。如果通pthread_mutex_trylock拿不到锁,程序会返回一个错误码而不会阻塞。
2.PTHREAD_MUTEX_ERRORCHECK
    对已经加锁的对象再加锁会返回死锁,其他线程再调用pthread_mutex_lock时会阻塞在线程的调用处。
3.PTHREAD_MUTEX_RECURSIVE
    允许重复加锁,加锁引用计数加1,解锁引用计数减1,当引用计数为0时该锁可被获取。
    

3.3.2 Linux信号量

信号量代表一定的资源数量,可以根据当前资源的数量按需唤醒指定数量的消费者线程,消费者线程一旦获取信号量,就会让资源减少指定的数量,如果资源数量为0,则会挂起所有的消费者线程。当有新资源到来时,消费者线程将又会被唤醒。

//信号量常见API#include<semaphore>//pshared 0表示在同一进程*享,1表示不同进程间可共享;value表示初始状态下的资源数量。int sem_init(sem_t* sem,int pshared, unsigned int value);int sem_post(sem_t* sem); //资源计数加一,并解锁该信号量对象,因sem_wait阻塞的线程会被唤醒int sem_destory(sem_t* sem);//销毁信号量int sem_wait(sem_t* sem);//如果资源计数为0,阻塞调用线程,直到资源计数大于零时被唤醒,唤醒后资源计数减1然后立即返回。int sem_trywait(sem_t* sem);//资源计数为0时不阻塞,直接返回-1,错误码被设置还曾EAGAIN,int sem_timedwait(sem_t* sem,const struct timespec* abs_timeout);//带有等待时间的版本1.abs_timeout不能为空,否则崩溃2.abs_timeout指的是绝对时间。比如想让函数等待5s,则要先获取当前时间,再加5s在abs_timeout指定的时间内,资源的引用计数要大于0,否则会返回-1,错误码被置成ETIMEDOUT。在使用wait系列函数时,会锁定信号量对象。trywait和timedwait获取失败会直接返回-1,然后可根据错误码处理。struct timespec{    time_t tc_sec;	//秒    long tv_nsec;	//纳秒};

3.3.3 Linux条件变量

当一个共享变量被多个线程操作时,需要使用互斥锁,有可能会导致效率问题。现在又一种机制:某个线程A在条件不满足的情况下主动更让出互斥体,由其他线程操作,线程A在此等待条件满足,一旦条件满足,线程A会被立刻唤醒。他的机制是其他线程在操作时发现条件满足,则会向线程A发信号并且让出互斥体。条件变量需要条件等待,但是条件等待不是条件变量的唯一功能。

pthread_mutex_lock(&mutex);
while(condition_is_false)
{
    pthread_mutex_unlock(&mutex);		//时间片可能被剥夺,另一个线程获取mutex并且接着执行,原线程就会永远阻塞等待在下一行。根本原因是释放互斥锁和条件变量等待唤醒不是原子操作。即解锁和等待唤醒必须在同一原子操作中
    cond_wait(&cv);
    pthread_mutex_lock();
}

//使用
//初始化和销毁
int pthread_cond_init(pthread_cond_t* cond, const pthread_cond_condattr_t* attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destory(pthread_cond_t* cond);
//等待唤醒
int pthread_cond_wait(pthread_cond_t* restrict cond,pthread_mutex_t restrict mutex);
int pthread_cond_wait(pthread_cond_t* restrict cond,pthread_mutex_t restrict mutex,const struct timespec* restrict abstime);
//被唤醒
int pthread_cond_signal(pthread_cond_t* cond);
int pthread_cond_boardcast(pthread_cond_t* cond);
pthread_cond_signal一次只唤醒一个线程,如果有多个线程调用了pthread_cond_wait,则哪一个线程被唤醒是随机的。pthread_cond_boardcast以唤醒所有因调用pthread_cond_wait而等待的线程。唤醒成功返回0,失败返回相应的错误码。

重点是要明白pthread_cond_wait在条件满足和不满足时的两种行为。

  1. pthread_cond_wait函数在阻塞时,会释放其绑定的互斥体并阻塞线程,因此在条用该函数前应该对互斥体加锁。
  2. 收到条件信号时,pthread_cond_wait会返回并对其绑定的互斥体进行加锁。

条件变量的虚假唤醒

使用while语句时,条件变量醒来后在此判断条件是否满足。

while(tasks.empty())
{
    pthread_cond_wait(cond);
}

if (tasks.empty())
{
    pthread_cond_wait(cond);
}

操作系统唤醒pthread_cond_wait时,tasks.empty()可能仍为true。存在没有线程发送唤醒消息但等待条件变量的线程醒来的情况,这就叫虚假唤醒。将条件(tasks.empty()的值)放在while循环中,意味着要条件和唤醒要同时满足,程序才能继续执行。

//TODO :为啥存在虚假唤醒?

条件变量信号丢失

如果一个条件变量信号在产生时(调用pthread_cond_signal或者pthread_cond_boardcast),没有相关线程调用pthread_cond_wait捕获该信号,该信号就会永久丢失,在此调用pthread_cond_wait会导致永久阻塞。在设计条件变量只会产生一次时尤其要注意。如:程序中有一批等待条件变量的线程,和一个只产生一次条件变量信号的线程,等待条件变量的线程一定要在生产条件变量的信号发出之前调用pthread_cond_wait。

3.3.4 Linux读写锁

共享变量访问的特点:多数情况下只是去读该变量的值,少数情况下才回去写。读请求之间不用同步,他们之间的并发访问时安全的,但是写请求必须锁住其他请求。

//读写锁的初始化和销毁#include<pthread.h>int pthread_rwlock_init(pthread_rwlock_t* rwlock,const pthread_rwlock_attr_t* attr);int pthread_rwlock_destory(pthread_rwlock_t* rwlock);pthread_rwlock_t rwlock = PTHREAD_RWLOCK_INITIALIZER;//请求读int pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);int pthread_rwlock_tryrdlock(pthread_rwlock_t* rwlock);int pthread_rwlock_timerdlock(pthread_rwlock_t* rwlock,const struct timespec* abstime);//请求写int pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);int pthread_rwlock_trywrlock(pthreadrwlock_t* rwlock);int pthread_rwlock_timewrlock(pthread_rwlock_t* rwlock,const struct timespec* abstime);//释放读写锁int pthread_rwlock_unlock(pthread_rwlock_t* rwlock);

读锁用于共享模式:如果当前读写锁已经被某个线程以读模式占有,则其他线程调用pthread_rwlock_t会立刻获得读锁,其它线程如果调用pthread_rwlock_wrlock则会阻塞在该处。

写锁用于独占模式:如果当前读写锁已经被某个线程以写模式占有,则其他线程调用pthread_rwlock_rdlock和pthread_rwlock_wrlock时,都会陷入阻塞,直到线程释放写锁。

//TODO:读写锁的属性(默认读锁优先)

3.4 C++/11/14/17 线程同步对象

3.4.1 std::mutex

互斥量 版本 作用
mutex c++11 基本互斥量
timed_mutex c++11 带有超时功能的互斥量
recursive_mutex c++11 可重入互斥量
recursive_timed_mutex c++11 带有超时功能的可重入互斥量
shared_mutex c++17 共享互斥量
shared_timed_mutex c++14 带有超时机制的可共享互斥量

该系列都有加锁(lock)、尝试加锁(trylock)和解锁的方法。有加锁就要有解锁,以避免死锁。新标准提供的封装可以代替RAII。

互斥量管理 版本 作用
lock_guard c++11 基于作用域的互斥量管理
unique_lock c++11 更加灵活的互斥量管理
shared_lock c++14 共享互斥量管理
scoped_lock c++17 多互斥量避免死锁管理
//以下是错误的写法,互斥锁的生存周期一定要比保护区的生命周期长。
void func()
{
    std::mutex m;
    std::lock_guard<std::mutex> guard(m);
    //临界资源区
}
lock再lock的行为未定义。
同一个线程对某个mutex再次加锁,应该使用std::recursive_mutex(c++17起)。

3.4.2 std::shared_mutex

std::shared_mutex的实现是利用操作系统底层实现的读写锁,也就是说在多读少写的情况下,要比std::mutex的操作效率要高。std::shared_mutex提供了lock方法和unlock方法分别用于获取写锁和解除写锁,lock_shared和unlockshared获取读锁和解除读锁。一般也称写锁为排它锁,读锁称为共享锁。

新标准中引入了与std::shared_mutex配和的两个对象,构造时对mutex加锁,析构时解锁。

  • std::unique_lock:加std::shared_mutex的写锁
  • std::shared_lock:加std::shared_mutex的读锁。

3.4.3 std::condition_variable

c++11提供了与Linux原生的条件变量类似的std::condition_variable类,还提供了等待条件变量满足的wait系列方法(wait,wait_for,wait_until方法)。发送条件信号时需要使用notify方法(notify_one,notify_all方法)。使用std::condition_variable对象时需要绑定1个std::unique_lock或者std::lock_guard对象。

std::condition_variable不需要再显示的初始化和销毁。

3.4.4 如何确保创建的线程一定能够运行

现在只要正确的使用线程创建函数,实际编码时对线程的返回值也不必判断,基本可以认为线程一定会创建成功,而且线程函数可以正常运行。

3.5 多线程使用锁的经验

3.5.1 减少锁的使用次数

多线程时,使用锁一般有以下损失:

  1. 加锁和解锁操作,本身有一定开销。
  2. 临界区的代码不能并发执行。
  3. 进入临界区的次数过于频繁,线程之间对临界区的争夺太激烈,若线程竞争互斥体失败,就会陷入阻塞并让出CPU,执行上下文切换的次数要远远多于不使用互斥体的次数。

所以可以尝试替代锁的方案,如无锁队列。

3.5.2 明确锁的范围

while(m_lst.empty()) //m_lst的判空也应该在锁的保护范围内。
{
    std::mutex_lock(&mymutex);
    m_lst.insert(tmp);
    std::mutex_unlock(&mymutex);
}

3.5.3 减小锁的粒度

减小锁的作用的临界区代码范围,临界区代码越少,多个线程排队进入临界区的时间就越短。

void TaskPool::AddTask(Task* task)
{
    std::lock_guard<std::mutex> guard(m_mutexLst); //guard锁保护m_mutexLst
    std::shared_ptr<Task> spTask;
    spTask.reset(task);
    m_mutesLst.push_back(spTask);
        
    m_cv.notify_one();
    }
}
//减小guard锁的使用范围
void TaskPool::AddTask(Task* task)
{
    std::shared_ptr<Task> spTask;
    spTask.reset(task);
    {
        std::lock_guard<std::mutex> guard(m_mutexLst);
        m_mutexLst.push_back(spTask);
    }
    
    m_cv.notify_one();
}

3.5.4 避免死锁的建议

  1. 函数内加锁,退出函数时一定要解锁。可用RAII或者std::lock_guard替代。
  2. 线程退出时一定要释放持有的锁。实际开发中可能会创建一些临时线程,线程执行完相应的任务会退出,这类线程持有了锁,推出线程时一定要记得释放。
  3. 多线程请求锁的方向要一致,避免死锁。假设现在又两个锁A和B,线程1在请求锁A后请求了锁B,线程2请求了锁B后请求了锁A,此时容易造成死锁。所以要么都先请求锁A,再请求锁B;或这先请求锁B,再请求锁A。
  4. 当需要同一个线程重复请求同一个锁时,需要明白递增锁引用计数,还是阻塞或者直接获得锁。

活锁:多个线程使用trylock系列函数时,由于相互谦让,导致即使某个时间段锁可用,需要锁的线程也拿不到锁。因此在实际编程时,应该尽量避免过多函数使用trylock系列函数而造成活锁。

3.6 线程局部存储

对于一个存在多个线程的进程来说,有时需要每个线程都自己操作自己的这份数据。这类似与c++类的实例属性,每个实例对象操作的都是自己的属性。这样的数据称为线程局部存储。

3.6.1 Linux提供的线程局部存储

//Linux中提供了一套函数实现线程的局部存储。int pthread_key_create(pthread_key_t* key,void (*destory)(void*));int pthread_key_delete(pthread_key_t* key);int pthread_setspecific(pthread_key_t* key,const void* value);void pthread_getspecific(pthread_key_t* key);调用成功时,会为线程局部存储创建一个新的key,用户通过这个key设置(pthread_setspecific)和获取(pthread_getspecific)数据。因为进程的所有变量都可以使用返回的键,所以key应该是一个全局变量。

gcc编译器提供了线程局部存储关键字__thread用于定义线程局部存储。

3.6.2 c++11的thread_local关键字

thread_local用来定义一个线程变量。使用线程变量时尤其要注意以下两点:

  • 对于线程变量,每一个线程都会有一个该变量的拷贝,互不影响,该局部变量一致存在,直到线程退出。
  • 系统的线程局部存储区域的内存空间并不大,尽量不要用这个空间存储大的数据块。如果不得不使用大的数据块,可以将大的数据快存储在堆内存中,再将指向堆内存的指针存储在线程局部存储区域。

3.7 c库的非线程安全函数

c库中的很多函数在编写时还没有多线程计数,有些直接就使用了静态变量或者全局变量,这导致了一些函数是非线程安全的,随这多线程编程计数的出现,很多函数有了多线程安全的替代品,如localtime_r和strtok_r函数。

3.8 线程池与队列系统的设计

3.8.1 线程池的设计原理

线程池只是一组线程。大多数时候我们需要执行一些异步任务,这些任务的产生和执行存在于整个程序的生命周期。于其让操作系统不断地创建与销毁线程,不如创建一组在整个程序生命周期内都不会退出的线程。基本要求是:当有任务需要执行时,线程能够自动拿到任务并执行,没有任务时这些线程处于阻塞或者休眠状态。

既然程序生命周期内会产生很多任务,可以把这些任务都放在“队列”中,它可以是全局变量或者链表。生产任务的线程是生产者,线程池中的线程是消费者。既然多个线程会同时操作这个队列,就需要对他进行加锁操作。除了创建线程池,还需要向队列投递任务,从队列中取出任务并处理问题,还需要考虑清理线程池、退出线程池的工作任务和清理任务队列。

3.8.2 环形队列

如果生产者和消费者的速度差不多,可以将队列改成环形队列,以节省内存空间,为了追求效率,可以将一些环形队列无锁化,以提高效率。

3.8.3 消息中间件

由于基于生产者/消费者模型衍生的队列系统在实际开发中很常见,以至于每个进程都需要这样一个队列系统。出于复用和解耦的目的,业界出现了很多独立的队列系统,这个队列系统以一个独立的进程运行,或以支持分布式的一组服务运行。这种独立的系统称为消息中间件。消息中间件的功能上可以进行扩展,如消费方式、主备切换、容灾容错、数据自动备份和过期数据自动清理等。

这种专门的队列系统,生产者和消费者将最大化解耦。利用消息中间件提供的对外消息接口,生产者只需要负责生产消息,消费者只负责消费,队列系统本身也不用官自己有多少生产者和消费者。

3.9 纤程与协程

//TODO 纤程

3.9.1 纤程

3.9.2 协程

线程是操作系统的内核对象。多线程编程时线程数过多会导致上下文频繁切换,CPU的缓存命中率会降低,对性能会有较大的损耗。如:在高并发网络编程时,使用一个线程服务一个socket是很不明智的做法,现在主流的做法是利用操作系统提供的基于事件的异步编程模型,用少量的线程来服务大量的网络链接和IO,但是采用异步和基于事件的编程模型,会使代码程序变复杂(逻辑割裂?),也加大了排错的难度。

协程可以被认为是应用层模拟的线程。协程避免了线程上下文切换的额外损耗,同时具有并发运行的优点,降低了编写并发程序的复杂度。对于高并发网络编程,可以为每个socket都开一个协程,在兼顾性能的同时代码逻辑也会编的清晰。

协程的概念比线程早。它是非抢占是调度,无法是西安公平的任务调度,也无法直接利用多核CPU的优势。现在最新版本的gcc11.1貌似完全支持了协程。

协程的内部实现都是基于线程,思路是维护一组数据结构和n个线程,真正的执行者还是线程,协程执行的代码被扔进一个待执行的队列中,由这n个线程从队列中拉出来执行,这就解决了线程的执行问题。协程的切换是调用了操作系统的异步IO(Golang),当异步函数返回busy或者blocking时,将现有执行序列压栈(避免的是线程的上下文切换?)让线程拉去另一个协程的代码执行。

协程流行的原因是大多数业务系统都秦湘语使用异步编程来提高系统性能,但是这就将线性的程序逻辑打乱,使得程序的逻辑非常复杂,堆程序状态的管理也变得非常困难。掌握了多线程技术,就能快速的学会协程。协程是未来?冲冲冲!!!

上一篇:2021.7.16快乐实验


下一篇:线程池原理及设计与实现