首页 C++杂记 - 3
文章
取消

C++杂记 - 3

C++杂记 - 3

std::bind, std::function 和 std::mem_fn

std::bind 包括但不限于mem_fn的功能,更为通用的解决方案

什么是bind?我们可以把它看作一个通用的函数适配器,它接受一个可调用对象,生成一个新的可调用对象来“适应”原对象的参数列表。

std::bind将可调用对象与其参数一起进行绑定,绑定后的结果可以使用std::function保存。std::bind主要有以下两个作用:

  • 将可调用对象和其参数绑定成一个仿函数;
  • 只绑定部分参数,减少可调用对象传入的参数。

语法:

1
auto newCallable = bind(callable, arg_list);

该形式表达的意思是:当调用newCallable时,会调用callable,并传给它arg_list中的参数。

需要注意的是:arg_list中的参数可能包含形如_n的名字。其中n是一个整数,这些参数是占位符,表示newCallable的参数,它们占据了传递给newCallable的参数的位置。数值n表示生成的可调用对象中参数的位置:_1newCallable的第一个待填充参数,_2为第二个待填充参数,以此类推。 注意这些占位符在std::placeholders名称空间内。

  • std::bind的返回值是可调用实体,可以直接赋给std::function

  • bind绑定类非静态成员函数时,第一个参数表示对象的成员函数的指针,第二个参数表示对象的地址,这是因为对象的成员函数需要有this指针。并且编译器不会将对象的成员函数隐式转换成函数指针,需要通过&手动转换(符合成员函数指针的赋值语法)。静态成员函数无需此操作。因为没有this指针。

    • 注意传入对象地址也可以传入对象的引用或对象本身。

    • 在类内使用该类的成员函数的时候,和类外使用一样。都需要取地址并且传入对象指针。只不过可以使用this替代。

      • 这个的常见案例是类内含有thread的时候,比如这种情况:

      • 1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        15
        16
        17
        
        struct test{
            test():th1(bind(&test::threadfunc, this)){}; //因为是类内使用该类成员函数,所以需要绑定,但是可以使用this替代。
            ~test(){
                if(th1.joinable()){
                    th1.join();
                }
                      
            }
            private:
                void threadfunc(){ //成员函数。
                    cout <<"hello world" << endl;
                }
            thread th1;
        };
        int main(){
            test mytest;
        }
        
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class my_class{
    public:
    int val;
    my_class(int x):val(x){}

    void getval(){
        cout << val << endl;
    }
    void add(int another){
        val = val + another;
        cout << val << endl;
    }
    static void staticfunc(int val){
        cout << "static" << val << endl;
    }
};
int main(){
    my_class obj1(10);
    auto task2 = bind(&my_class::getval, &obj1); 	//调用无参函数,this参数预绑定。成员函数指针遵循语法。
    auto task3 = bind(&my_class::add, &obj1, placeholders::_1);	//调用有参函数,this参数预绑定。
    auto task4 = bind(&my_class::add, placeholders::_1, placeholders::_2); //调用有参函数,this参数使用占位形式。
    auto task5 = bind(&my_class::staticfunc, placeholders::_1); //静态成员函数没有this指针。无需传入。
    task2();
    task3(3); 
    task4(&obj1, 3); //this使用占位形式,需要调用时传入。
    task5(3);
    return 0;
}
  • 默认情况下,bind的那些不是占位符的参数会被拷贝或移动(以值传递)到bind返回的可调用对象中。如果需要使用引用传递,必须使用ref
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void mypred(int a, int b, vector<int>& c){ 尽管这里函数头使用了引用,但是bind在预绑定的时候(也就是非placeholder参数)默认会拷贝一份原参数进行传入,也就是默认是值传递。所以这里的容器c其实是一个局部变量。
    if(a < b){
        cout <<"yes" << endl;
        c.push_back(a);
        
    }
}

int main(){
    vector<int> test = {1};
    vector<int> final;
    final.reserve(10);
    for_each(test.begin(), test.end(), bind(mypred, placeholders::_1, 3, ref(final))); 所以这里我们如果要使用引用来传递final,则必须要使用ref函数来获取其引用。
    for(auto i = final.begin(); i != final.end(); i++){
        cout << *i << endl;
    }
    return 0;
}

使用std::bind搭配priority_queue 和 vector的自定义排序。

请参考STL-1笔记

慎用std::bind, 如果可能的话使用lambda

https://lefticus.gitbooks.io/cpp-best-practices/content/08-Considering_Performance.html

https://stackoverflow.com/questions/49246242/efficiency-of-stdbind-vs-lambda

https://youtu.be/ZlHi8txU4aQ

https://mp.weixin.qq.com/s/VOqPjW48DG3gp60EqkYrTQ

mem_fn 成员函数适配器 把成员函数转为函数对象,使用对象指针或对象(引用)进行绑定

个人实验得出的结论:mem_fnbind的子集

注意mem_fn不能调用类静态成员函数。因为没有this指针。

mem_fn就是强制给你把类对象塞进去。

mem_fn无法接受额外参数。也就是无法使用placeholder

mem_fn的核心功能是把类成员函数转换成不需要类成员就可以调用的形式。就是把this指针绑定到类成员函数的隐藏this参数上。但是调用的时候依旧需要传入一个对象地址。。就很废物。

比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class my_class{
    public:
    int val;
    my_class(int x):val(x){}

    void getval(){
        cout << val << endl;
    }
    void add(int another){
        val = val + another;
        cout << val << endl;
    }
    static void staticfunc(int val){
        cout << "static" << val << endl;
    }
};
int main(){
    my_class obj1(10);

    auto task1 = mem_fn(&my_class::getval); 
    task1(&obj1); //把对象地址传入

    auto task2 = mem_fn(&my_class::add);
    task2(&obj1, 5);

    //auto task3 = mem_fn(&my_class::staticfunc); 错误!静态成员函数不行。

    auto task1_1 = bind(&my_class::getval, placeholders::_1); //bind就得多写个参数
    task1_1(&obj1);
    
    auto task1_2 = bind(&my_class::getval, &obj1);
    task1_2();

    return 0;
}

std::placeholders

我们提到了,我们使用bind的时候,placeholder是待填充参数。什么叫待填充?这是一种具象解释。其实bind相当于生成了一个新的可调用对象,拥有两个参数。但是结合原本的可调用对象来看,我们有三个参数。所以站在原本的可调用对象角度来看,这原来的可调用对象中多出来的一个参数相当于已填充参数。剩下的两个参数相当于待填充。

语法和例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void testfunc(int x, int y, int z){
    cout << x << endl;
    cout << y << endl;
    cout << z << endl;
}
int main(){

    auto callable1 = bind(testfunc, placeholders::_1, placeholders::_2, 5);
    callable1(8,80);
    //输出8 80 5
    auto callable2 = bind(testfunc, placeholders::_2, placeholders::_1, 5);
    callable2(8,80);
    //输出80 8 5

    auto callable3 = bind(testfunc, placeholders::_1, 5, placeholders::_2);
    callable3(8,80);
    //输出8 5 80
    auto callable4 = bind(testfunc, placeholders::_1, 5, placeholders::_3); //错误。新的调用对象只有两个参数。这里3超了。并placeholders且必须按序。不可跨越。不能没有2的时候直接使用3,尽管可能待填充参数一共有3个。
    callable4(8,80);
    
    auto callable5 = bind(testfunc, placeholders::_1, 5, placeholders::_1); //极端情况。占位符可以重复。
    callable5(8);
    //输出8 5 8
}

注意

  • placeholder编号必须按序。此处按序指的是不能没有2的时候直接使用3
  • 并且编号不能大于待填充参数的数量。

参考资料:https://elloop.github.io/c++/2015-12-15/learning-using-stl-12-std-bind

std::function

大家都用过函数指针。具体就不赘述了。稍微回忆一下函数指针的语法:

1
2
void(*another_a_ptr_ptr)(void) = (void(*)(void))*(long long*)(*a_ptr);
void(*another_a_ptr_ptr)(void); 这个函数指针的类型是void(*)(void)

成员函数指针的用法和声明在杂记2。

std::function是一个多态可调用对象包装器,是一个类模板,可以容纳除所有可调用对象(类成员函数和指针需要bind一下),它可以用统一的方式处理函数、函数对象、函数指针,lambda并允许保存和延迟它们的执行。基本上任何有函数调用运算符重载operator()的对象都可以被function包装。

注意,std::function支持多态。

一个例子让你知道function怎么用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int myfunc(int a){
    cout << a << endl;
    return a;
}

int myfunc1(const int& a){
    cout << a << endl;
    return a;
}

int myfunc2(int&& a){
    cout << a << endl;
    return a;
}

int main(){
    function<int(int)> func = myfunc;
    func(4);

    function<int(const int&)> func1 = myfunc1;
    func1(4);

    function<int(int&&)> func2 = myfunc2;
    func1(4);

    return 0;
}

说白了就是类型是你的函数返回值类型+参数类型。

因为std::function可以保存lambda。所以当函数签名的参数是std::function对象的时候,可以直接传入一个类型匹配的lambda对象。有隐式类型转换

我们前面提到过 bind的返回值是可调用实体,可以直接赋给std::function

1
2
3
4
5
function<void(void)> task1_2 = bind(&my_class::getval, &obj1);
task1_2();

function<void(int)> task2 = bind(&my_class::add, &obj1, placeholders::_1);
task2(5);

有一个问题是,这里必须要显式把对象地址直接塞进去,不能把对象地址用占位符。因为参数对不上了。(个人实验)

故而,std::function的作用可以归结于:

  • std::function对C++中各种可调用实体(普通函数、Lambda表达式、函数指针、以及其它函数对象等)的封装,形成一个新的可调用的std::function对象,简化调用;
  • std::function对象是对C++中现有的可调用实体的一种类型安全的包裹(如:函数指针这类可调用实体,是类型不安全的)。

慎用std::function 性能极差

虚函数的开销是常规函数的5倍,std::function是6倍以上。

原因是std::function的底层还是使用虚函数,进行多态调用。因为使用了类型擦除。同时,std::function是通用的,并且它不知道包装的可调用对象多大(比如lambda就是动态大小的,我们在lambda一节中提到了lambda的大小取决于捕获参数的数量和方式)。但是可调用对象必须要被std::function所保存,这样才能在std::function对象被移动或复制的时候正确的移动或复制。它的内部有一个缓冲区。如果可调用对象足够小,则不需要新开内存。所以当可调用对象大于某个阈值的时候,它需要使用动态内存分配来存储。同时,有几种方法可能避免。比如使用std::ref包装参数,或者在使用lambda函数初始化std::function对象时,对应的lambda可以使用引用捕获参数避免lambda的大小膨胀。

https://wizmann.tk/cpp-type-erasure-and-std-function.html

https://stackoverflow.com/questions/5057382/what-is-the-performance-overhead-of-stdfunction

https://stackoverflow.com/questions/18453145/how-is-stdfunction-implemented

https://blog.demofox.org/2015/02/25/avoiding-the-performance-hazzards-of-stdfunction/

关于std::function和多态

std::function既然叫做多态函数包装器,那么就可以正确处理多态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class father{
    public:
        father()= default;

        virtual void func(){
            cout <<"father" << endl;
        }
};

class child:public father{
    public:
        child() = default;
        void func(){
            cout << "child" << endl;
        }
};
int main(){

    father* fptr = new child;
    function<void(void)> funcobj = bind(&father::func, fptr);
    funcobj(); //输出child
    return 0;
}

关于源码解析

脸都看绿了。

https://blog.csdn.net/zdy0_2004/article/details/50652934

https://blog.csdn.net/weixin_43798887/article/details/116571325

https://zhuanlan.zhihu.com/p/560964284

https://zhuanlan.zhihu.com/p/66301236

在函数中传递std::function对象,搭配bind并考虑使用placeholders

为了方便起见。此处仅使用全局函数为例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void test(int a, int b, const string& c){
    cout << a << b << c << endl;
}

void funcinput(function<void(int, int, const string&)> in_func){//注意函数入参签名
    in_func(1,2,"abc");
}

void another_funcinput(function<void(int, const string&)> in_func){ //注意函数入参签名
    in_func(2,"abc");
}
int main(){
    funcinput(bind(&test, placeholders::_1, placeholders::_2, placeholders::_3)); //输出12abc
    another_funcinput(bind(&test, 1, placeholders::_1, placeholders::_2));//输出ab123
    return 0;

}

我们打算将std::function对象传入函数。

注意在搭配bind使用的时候。注意function的类型。[下方例子全部忽略第一个实际参数(为函数地址)]。**`function`的类型为函数返回类型+除预先绑定的参数以外的全部参数类型(所有`placeholders`代表的类型)。**

  • bind(&test, placeholders::_1, placeholders::_2, placeholders::_3)的时候,由于三个参数全部为placeholders,所以此时function的类型为目标函数的返回值类型+全部参数类型。为function<void(int, int, const string&)>
  • (bind(&test, 1, placeholders::_1, placeholders::_2)的时候,由于第一个参数为预绑定,只有两个placeholders,所以此时function的类型为目标函数的返回值类型+除第一个外,也就是剩余的参数类型。为function<void(int, const string&)>

一个稍微复杂的包裹类练习。使用了模板和bind

我们这一段代码的目的是想简单模拟一下unique_ptr调用自定义删除器的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class myInt{
    public:
    int val1;
    myInt() = default; //要点1
    myInt(int x):val1(x){}
    void operator()(int a){ //要点2
        cout << a <<endl;
        cout <<"called" << endl;
    }
    
};
void global_func(int x){
    cout << x << endl;
    cout <<"global" << endl;
}
template<typename T1, typename T2>
class anotherone{
    public:
        T1 callable; //要点3 
        T2 value;
        anotherone() = default;
        anotherone(T2 val):value(val){} //要点4
        anotherone(T1 func, T2 val): callable(func), value(val){} //要点5
        void operator()(){
            callable(value);
        }
};
int main()
{
    myInt tests; //要点6
    auto objbind = bind(&myInt::operator(), &tests, placeholders::_1); //要点7
    typedef decltype(objbind) mytype; //要点8
    anotherone<mytype, int> myobj2(objbind, 200); //要点9 T1是bind类型
    anotherone<myInt, int> myobj(234); //要点10 T1 是 myInt类型
    myobj2();
    myobj();
    
    anotherone<void(*)(int), int> myobj3(global_func, 345);
    myobj3();
	return 0;
}
  • 要点1 myInt必须提供默认构造。原因是我们的anotherone在不接受函数对象做为可调用对象的时候,会利用第一个模板参数的类型构建一个 第一个模板参数类型的对象。也就是myInt对象。如果没有默认构造则会失败
  • 要点2 operator()没有固定实现。返回值可以依靠不同需求做决定
  • 要点3等同于要点1。在不接受函数对象做为可调用对象的时候,这个东西是第一个模板参数的类型的对象。然后我们会调用这个对象的operator()来进行调用。如果是一个可调用对象,则利用这个可调用对象进行调用。比如bindfunction或函数指针。
  • 要点4是为了匹配不提供调用对象只提供类型的情况
  • 要点5是为了匹配提供调用对象的情况
  • 要点6和要点7是bind成员函数的时候必须传入一个对象地址做为this指针
  • 要点8是因为我们必须要显式指明类型,所以要有一个decltype辅助我们判断类型。
  • 要点9和要点10就是实际模拟传入一个可调用对象或不传入可调用对象,只通过类型进行调用。

lambda, bind和function的简单准则

在性能关键点,一定要避免使用std::function或函数指针调用函数。尽可能避免使用bind和lambda。如果一定要使用,优先选择lambda,其次是bind,然后是函数指针,最后才是std::function

https://stackoverflow.com/questions/49246242/efficiency-of-stdbind-vs-lambda

std::call_once 和 std::once_flag

原型:

1
2
template< class Callable, class... Args >
void call_once( std::once_flag& flag, Callable&& f, Args&&... args );

通过call_in_once执行的可调用对象可以保证在多线程的状态下仅被(一个线程)执行一次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
once_flag my_flag1; //这个flag是一次性使用的。必须要全局。而且如果有两个东西需要call_once就要两个flag
once_flag my_flag2;
void func1(){
    cout <<"func1" << endl;
}

void func2(int s){
    cout <<"func2" << s << endl;
}
void thread_exe(){
    //once_flag my_flag1; //错误 放在这里就是线程函数的局部变量。这样每个线程就会有自己的flag
    //once_flag my_flag2;
    call_once(my_flag1, func1);
    call_once(my_flag2, func2, 1);
}

int main(){
    thread mythread1(thread_exe);
    thread mythread2(thread_exe);
    mythread1.join();
    mythread2.join();

    return 0;
}

注意事项:

  • once_flag对象对于所有线程来说应该是全局变量(或等同于全局变量的形式)。因为once_flag对象只能被使用一次。如果是局部变量,则放在这里就是线程函数的局部变量。这样每个线程就会有自己的once_flag对象,就无法起作用。
  • 因为每一个once_flag对象是一次性的。所以如果有两个东西需要call_once就要两个once_flag对象
  • flag没有标记为done,所有线程都会阻塞在once_flag这里,此时:
    • 如果某个Callable抛出异常,则call_once会选择下一个等候的线程重新执行call_once动作。
    • 如果成功执行了Callable,解除所有等候线程的阻塞并给flag标记为done

参考:https://blog.csdn.net/qq_31175231/article/details/77916028

https://blog.csdn.net/XJF199001/article/details/51435845

异步API。std::future, std::promise, std::packaged_task, std::async

想要进行多线程编程,这四个看起来高级的API是必不可少的。我们来看一看这一套异步编程套组里的四个组件到底有什么关系,他们是干什么的。

参考资料:https://segmentfault.com/a/1190000039201271

https://murphypei.github.io/blog/2019/04/cpp-concurrent-4

std::future / std::shared_future

< future >头文件功能允许对特定提供者设置的值进行异步访问,可能在不同的线程中。 这些提供程序(要么是promise 对象,要么是packaged_task对象,或者是对异步的调用async)与future对象共享共享状态:提供者使共享状态就绪的点与future对象访问共享状态的点同步。

上面这段话翻译过来就是我们可以理解为future是一个时间胶囊, 一个对象。这个对象的提供程序(如promise)里面储存了我们希望以后拿到的东西。然后我们用future来和提供程序连接起来,一旦共享状态转变为就绪,就可以拿到提供程序里面的东西。

  • 所以说,future对象提供了一种让我们访问异步操作的结果的机制。

我们看一下future的正式定义和具体细节。

  • future 是一个对象,可以从某个提供对象或函数中检索值,如果在不同线程中,则可以正确同步此访问。
  • future的模板参数类型是其异步操作结果的类型。
  • 它提供了一种访问异步操作结果的机制。从字面意思上看它表示未来,这个意思就非常贴切,因为它不是立即获取结果但是可以在某个时候以同步的方式来获取结果。我们可以通过查询future的状态来获取异步操作的结果。future_status有三种状态:
    • deferred异步操作还未开始。共享状态包含一个延迟函数,因此只有在明确请求时才会计算结果。(主要用于async函数的std::launch::deferred参数。) (共享状态持有的函数正在延迟运行,结果将仅在显式请求时计算
    • ready异步操作已经完成(共享状态就绪
    • timeout:异步操作超时。在指定的超时持续时间过去之前,共享状态尚未准备好。(共享状态在经过指定的等待时间内仍未就绪
  • “有效” future 对象,只能通过调用以下函数之一来构造:
    • async
    • promise::get_future
    • packaged_task::get_future
  • 默认构造的 future 对象是无效的(除非移动(move)分配一个有效的 future)。
  • 在有效的 future 上调用 future::get 会阻塞线程,直到提供程序准备好共享状态(通过设置值或异常)。这样,两个线程可以通过一个线程同步,等待另一个线程设置值。
    • get() 调用会改变其共享状态,不再可用,也就是说 get() 只能被调用一次,多次调用会触发异常。如果想要在多个线程中多次获取产出值需要使用 shared_future
  • 共享状态的生存期至少要持续到与之关联的最后一个对象释放它或销毁它为止。因此,如果与 future 相关联,共享状态可以在最初获得它的对象(如果有的话)之后继续存在。

20190506204825200

什么叫共享状态? – 整理自EMC++ 条款38

我们先来思考一下。我们知道了,我们可以通过promise,packaged_task拿到一个future对象。future对象是一个句柄,用于让我们访问异步操作的结果。在我们的直观想象中,可能调用方和被调用方的关系是这个样子:

QQ截图20230224205647

  • 这时候有了第一个问题。被调用方的结果储存在哪比较好?在调用方使用get之前,可能这个异步操作已经执行完毕。同时,我们只能给promise设定值,而不能从promise中获取值。因此结果不会储存在被调用方的promise对象中。
  • 同时,这个结果也不能存在调用方的future对象中。因为我们可能通过future对象创建其他的shared_future对象。而shared_future对象可能会有多个。那么这么多对应同一个结果中的future对象中,哪一个应该包含结果呢?

所以future和结果应该储存在两个位置。这个位置被称之为共享状态。共享状态通常使用堆上的对象来表示,但是其型别、接口和实现标准皆未指定。标准库作者可以自由地用他们喜好的方法去实现共享状态。

所以说,调用方和被调用方的关系是这个样子:

QQ截图20230224212325

共享状态是有引用计数的。future~packaged_taskpromise对象的析构函数对其负责。所以在一些情况下,future对象的析构函数会由于共享状态的原因被阻塞。请查看async部分。同时部分资料来自这里

  • 但是注意,只有future对象的析构会阻塞,其他的不会。因为剩下两个对象的析构函数是抛弃(abandon)共享状态。而future对象的析构是释放(release)共享状态。

关于到底什么是共享状态,这个答案不太好。https://stackoverflow.com/questions/62241240/how-to-comprehend-stdfuture-shared-state

future::valid()

检查有效的共享状态

  • 返回 future 对象当前是否与共享状态关联。
  • 对于默认构造的 future 对象,此函数返回 false (除非将有效的 future 分配给移动对象)。
  • future 只能由某些提供函数(如, async, promise::get_futurepackaged_task::get_future)使用有效的共享状态进行初始化。
  • 一旦使用 future::get 检索了共享状态的值,则调用此函数返回 false (除非移动分配了一个新的 future).

返回值

  • 如果对象与共享状态关联,则为 ture。
  • 否则为假。

特别注意。返回值为true不代表此时共享状态已经就绪。只能表明对象与共享状态关联。

future::wait

阻塞并等待共享状态就绪(结果可用)

future::get

可以理解为包含了wait的操作。因为他调用了wait

阻塞并等待共享状态就绪(结果可用),返回存储在共享状态中的值(或引发其异常)

  • 当共享状态就绪时,返回存储在共享状态中的值(或引发其异常)。
  • 如果共享状态尚未准备好(即提供程序尚未设置其值或异常),则该函数将阻塞调用线程直到准备就绪。在此前,调用了get的函数不会返回。
  • 共享状态就绪后,该函数将取消阻塞并返回(或引发异常)以释放其共享状态。这时 future 对象不再有效对于每个 future 的共享状态,此成员函数最多应被调用一次
  • 提供者准备好共享状态和返回此函数之间是同步的。

shared_future

std::shared_futurestd::future 类似,但是 std::shared_future 可以拷贝、多个 std::shared_future 可以共享某个共享状态的最终结果(即共享状态的某个值或者异常)。shared_future 可以通过某个 std::future 对象隐式转换(参见 std::shared_future 的构造函数),或者通过 std::future::share() 显示转换,无论哪种转换,被转换的那个 std::future 对象都会变为 not-validstd::shared_future 的成员函数和 std::future 大部分相同,这个地方就不一一展开了,需要的请查阅官方文档。

我们可以看到,想要创建一个有效的future对象必须依靠剩下的三个API。我们就来进一步看看剩下的几块内容。

std::promise

promise是剩下三个当中最为“原始,底层“的API。

promise 本质是一个类似我们打印输出中占位符的东西,你可以理解它就是一个等待数据装填的坑,它是一个“承诺”,承诺未来会有相应的数据(模板实现)。因为这是一个“承诺”,所以创建的时候是没有东西的,所以我们需要知道这个异步操作什么时候能有东西,好实现“承诺”,所以 promise 可以通过调用 get_future() 返回一个 future 对象,让你去了解这个承诺是否完成了。因此,promise 是存放异步操作产出值的坑,而 future 是从其中获取异步操作结果,二者都是模板类型。

这里理解为我们的future对象是一个接口,建立起与promise对象的联系。我们使用promise对象来进行异步操作,所以能看到我们并不需要把future对象传入线程。但是我们会通过future对象去获知这个异步操作的结果是否就绪,也就是promise对象是否已经包含了我们期望的结果。然后我们可以通过future来获取结果。通过搭配future和promise,我们可以安全的进行线程间通信,不需要显式同步

promise和future就是一个异步操作的两个端点。我们把结果储存在promise,然后通过future提取。

  • promise 是一个对象,由 future 对象(可能在另一个线程中)检索,并提供一个同步点。
  • 通过调用成员get_future,可以将该共享状态与 future 对象关联。 调用之后,两个对象共享相同的共享状态:
    • promise对象是异步提供程序,应在某个时候为共享状态设置一个值。set_value
    • future 对象是一个异步返回对象,可以检索共享状态的值,并在必要时等待其准备就绪。
    • 理解为我们通过promiseget_future来创建我们的future对象。之后通过future对象(来检索)获得一个共享状态(结果)。
    • 注意 每个std::promise 对象只应当使用一次。

20190506203234388

promise::set_value

  • 以原子方式将值存储到共享状态(就是把promise对象储存的值设置好),并且改变该状态变为就绪状态。(设置共享状态)
  • 如果与同一共享状态关联的future对象当前正在等待对future::get的调用,则它将取消阻塞并返回val。
    • 因为future的get/wait会阻塞等待共享状态被设置完毕。

看看简单代码例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//使用promise进行异步
void accumulate2(std::vector<int>::iterator first, //注意不太需要返回值了。因为异步返回的值被储存在了promise对象中
                std::vector<int>::iterator last,
                std::promise<int> accumulate_promise) //函数头要有promise对象定义。
{
    int sum = std::accumulate(first, last, 0);
    accumulate_promise.set_value(sum);  // 将结果存入,并让共享状态变为就绪以提醒future
    //如果不set_value,那么调用了future::get/wait的线程将一直被阻塞。
}
 
int main()
{
    // 演示用 promise<int> 在线程间传递结果。
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::promise<int> accumulate_promise; //声明promise对象
    std::future<int> accumulate_future(accumulate_promise.get_future()); //通过promise对象的get_future来初始化(获取)future对象
    
    std::thread work_thread(accumulate2, numbers.begin(), numbers.end(),
                            std::move(accumulate_promise)); //启动线程。设置好执行函数和传入参数。注意必须要把promise对象一并传入(移入)
    //!这里也可以用ref。但是我们accumulate2的函数头要了个值。promise又禁用了拷贝构造所以这里目前只可以move。如果accumulate2函数头改为&就可以用ref
    // !可以用ref但是不推荐。因为如果你不转移所有权,万一你多个线程并发访问了promise,可能会有问题,
    !而且这也不符合设计,一般来说只应该让一个线程持有promise
    //accumulate_future.wait();  //等待结果 这个可以去掉,直接用get就可以
    
    std::cout << "result=" << accumulate_future.get() << '\n'; //get阻塞住等待共享对象变为ready。然后获取结果。
    work_thread.join();  //阻塞等待线程执行完成
 
    getchar();
    return 0;
}

std::packaged_task

packaged_task比promise高级一点。直观来看就是参数少一点,操作少一点。稍后我们会有一个简单的对比。

packaged_task 是对一个任务的抽象,我们可以给其传递一个函数来完成其构造。相较于 promise,它应该算是更高层次的一个抽象,同样地,我们可以将任务投递给任何线程去完成,然后通过 packaged_task::get_future() 方法获取的 future 对象来获取任务完成后的产出值。总结来说,packaged_task 是连数据操作(比如set_value)都封装进去了的 promisepackaged_task 也是一个类模板,模板参数为函数签名,也就是传递函数的类型。

1
2
template <class T> packaged_task;     // undefined
template <class Ret, class... Args> class packaged_task<Ret(Args...)>;
  • std::packaged_task包装可调用对象,并允许异步检索其结果。(可调用对象是重点)
  • 类似于 std::function, 但是会自动将其结果传输到 future 对象。
  • 对象内部包含两个元素:
    • 存储的任务是一些可调用对象(例如,函数指针,成员或函数对象的指针),其调用签名应采用 Args… 中类型的参数,并返回 Ret 类型的值。
    • 共享状态,该状态能够存储调用存储的任务(类型为 Ret)的结果,并且可以通过 future 来异步访问。
  • 通过调用成员 get_future 将共享状态与 future 对象关联。调用之后,两个对象共享相同的共享状态:
    • packaged_task 对象是异步提供程序,通过调用存储的任务,可以在某个时刻将共享状态设置为就绪。
    • future 对象是一个异步返回对象,可以检索共享状态的值,并在必要时等待其准备就绪。

2019050621124831

看看代码实例,做一下比较:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//? 使用packaged_task进行异步
int accumulate(std::vector<int>::iterator first,
                std::vector<int>::iterator last) //注意函数头,我们没有像promise那样需要传入promise对象。
{
    int sum = std::accumulate(first, last, 0);
    return sum;  //所以也不需要promise的set_value
}
 
int main()
{
    // 演示用 packaged_task 在线程间传递结果。
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::packaged_task<int(std::vector<int>::iterator,std::vector<int>::iterator)> accumulate_task(accumulate);
    //创建packaged_task对象。注意这里有区别,下面详说。
    std::future<int> accumulate_future = accumulate_task.get_future(); //通过packaged_task对象的get_future来创建future对象
    std::thread work_thread(std::move(accumulate_task), numbers.begin(), numbers.end()); //启动线程。注意这里有区别了。
    /*
    @ 使用move的主要原因还有一个就是按值传递函数参数会调用拷贝构造。packaged_task禁用了拷贝构造,要么传引用要么换成右值引用转移所有权。
    */
    //accumulate_future.wait();  //等待结果 可以和下面的get合并
    std::cout << "result=" << accumulate_future.get() << '\n';
    work_thread.join();  //阻塞等待线程执行完成
 
    getchar();
    return 0;
}

我们来说一说几点具体区别

  • 我们提到了,packaged_task包装的是可调用对象。但是promise仅仅是一个包装着异步执行结果的对象。所以:
    • packaged_task对象的模板类型是函数签名,因为他包装了可调用对象。
    • 所以线程执行的时候只需要移入packaged_task对象即可,无需传入函数本身。因为已经被包装了。
    • 所以包装的函数无需额外参数,函数内无需额外动作。
    • 因为promise会储存异步任务的结果,所以函数的返回值可以去掉。
  • 其实理解了上面那一点,就理解了packaged_taskpromise的区别。可以简单理解为promise是一个包装了执行结果的对象。而packaged_task是一个包装了整个任务的对象,它不仅包装任务执行结果,而且包装任务本身。

  • 注意它的模板头参数
1
2
template <class _Ret, class... _ArgTypes>
class packaged_task<_Ret(_ArgTypes...)> 

这样做的目的是让它的类型看起来更像函数。

std::async

async是最高级的一个API,代码简单,比较高层。它其实是封装了thread packged_task的功能,使异步执行一个任务更为方便

async是函数模板,不是类模板。

1
2
3
4
5
6
7
8
9
unspecified policy (1)    
template <class Fn, class... Args>
  future<typename result_of<Fn(Args...)>::type>
    async (Fn&& fn, Args&&... args);

specific policy (2)    
template <class Fn, class... Args>
  future<typename result_of<Fn(Args...)>::type>
    async (launch policy, Fn&& fn, Args&&... args);
  • 异步调用函数在某个时刻调用 fn (以 args 作为参数),返回时无需等待 fn 执行完成。(都说了是异步)
  • 可以通过返回的 future 对象(通过调用其成员 future::get)来访问 fn 返回的值。
  • 第二个版本 (2) 允许调用者选择特定的启动策略,而第一个版本 (1) 使用自动选择,就好像调用 (2) 并将 launch::aysnc | launch::deferred 作为策略。
  • 该函数在共享状态下临时存储使用的线程处理程序。一旦完成 fn 的执行,共享状态将包含 fn 返回的值并准备就绪。
launch::async启动一个新的线程以调用 fn (就像使用 fn 和 args 作为参数构造线程对象,并访问返回的 future 的共享状态将其联接)std::thread(std::forward<F>(f), std::forward<Args>(args)...)
launch::deferred意味着函数可能只会在std::async返回的future对象调用get或wait时执行。(函数在future对象调用了get或wait的时候才会开始执行)。不会产出新的线程。该任务会在调用线程中执行。当调用get或wait时,函数会同步执行,即调用者会阻塞直到函数运行结束。如果get或wait没有被调用,函数就绝对不会执行
launch::async | launch::deferred该功能自动(在某个时候)选择策略。这取决于系统和库的实现,他们通常会针对系统中当前的并发可用性进行优化

看一下细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//? 使用async进行异步

int accumulate3(std::vector<int>::iterator first, std::vector<int>::iterator last){
    std::cout <<"async begin" << std::endl;
    int sum = std::accumulate(first, last, 0);
    std::cout <<"async end" << std::endl;
    return sum;
}

int main(){
    std::cout <<"main begin" << std::endl;
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::future<int> accumulate_future = std::async(std::launch::async, accumulate3, numbers.begin(), numbers.end()); //ver1
    std::future<int> accumulate_future = std::async(std::launch::deferred, accumulate3, numbers.begin(), numbers.end()); //ver2
    Sleep(50); //睡眠五秒
    std::cout << "result=" << accumulate_future.get() << '\n';
    std::cout <<"main end" << std::endl;
    return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
ver1 输出:
main begin
async begin
async end
result=21
main end

ver2 输出:
main begin
result=async begin
async end
21
main end

调用方式和thread很像,没什么区别。没有花里胡哨的东西。但是注意下细节。

  1. 在async模式中,一旦开始了调用,则会立刻创建子线程开始任务执行。所以我们看到尽管主线程睡眠了一下,但是并不影响异步线程的执行。所以async begin, async end和result=21是可以打印的。因为get的时候任务已经完成(或者是阻塞至任务完成)。
  2. 在deferred模式中,只有get/wait调用的时候任务才开始执行。所以result=先打印出来,然后直到调用get函数的时候,才开始执行任务,任务执行完毕后结果才能拿到,所以会先打印result=, 然后在调用线程(此处是主线程)中执行函数,拿到结果后打印21。
  • 传参的时候如果需要使用只支持移动的参数比如unique_ptr,则必须使用move显式移动。
1
2
3
4
5
6
7
8
9
10
11
unique_ptr<int> testfunc(unique_ptr<int> ptr){
    *ptr = *ptr + 1;
    return ptr;
}

int main(){
    unique_ptr<int> myptr = make_unique<int> (5);
    future<unique_ptr<int>> my_future = async(std::launch::async, testfunc, move(myptr)); //显示使用move转移所有权。
    cout << *(my_future.get()) << endl;
    return 0;
}
  • 如果async函数的返回值没有一个东西接住他(显式获取返回值),因为async会创建临时的future对象, 所以这个临时对象的析构函数会阻塞住调用线程直至异步线程执行完毕。因为我们不能让异步线程返回值的时候,这个临时对象已经被销毁了。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
unique_ptr<int>  testfunc(unique_ptr<int> ptr){
    *ptr = *ptr + 1;
	sleep(5); //睡眠五秒
    return ptr;
}

int main(){
    unique_ptr<int> myptr = make_unique<int> (5);
	cout << "running" << endl;
    async(std::launch::async, testfunc, move(myptr)); //卡在这里5秒。因为没有获取返回值。直到任务完成后赋值给临时future对象之后才能执行future临时对象的析构。
    cout <<"blocked" << endl;
    return 0;
}

这段代码执行的时候,调用线程会由于异步线程没有执行完毕,而且因为没有获取返回值,所以卡在async这行。直到异步线程执行完毕后赋值给临时future对象之后才能执行future临时对象的析构。才会继续输出blocked。

  • 如果async函数的返回值有future对象接住他(显式获取返回值),而且没有对future对象用使用get来阻塞主线程,因为满足下面的条件,所以主线程依旧会等待异步线程结束。因为该future对象的析构函数被阻塞了
    • 注意:此条件仅限于如下条件为真:
      • future对象以 std::async 的调用创建,且共享状态仍未就绪,且 this 是到共享状态的最后引用。
        • 最后面那个条件原文是:this was the last reference to the shared state. 我的理解其实是:这个future对象是async调用创建的那个对象的最后的引用。
          • 也就是如果当前的future对象此时是async调用创建的那个future对象,唯一与其有关联的实例。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int accumulate3(std::vector<int>::iterator first, std::vector<int>::iterator last){
    std::cout <<"async begin" << std::endl;
    int sum = std::accumulate(first, last, 0);
    sleep(5); //异步线程睡眠5秒
    std::cout <<"async end" << std::endl;
    return sum;
}


int exe(){
    std::cout <<"exe begin" << std::endl;
    std::vector<int> numbers = { 1, 2, 3, 4, 5, 6 };
    std::future<int> accumulate_future = std::async(std::launch::async, accumulate3, numbers.begin(), numbers.end()); //通过async调用创建的future对象,且共享状态仍未就绪,且除了这个future对象以外,没有其他的对共享状态的引用。所以会阻塞。
    //std::cout << "result=" << accumulate_future.get() << '\n'; 没有对`future`对象用使用`get`来阻塞主线程
    std::cout <<"exe end" << std::endl;
    return 200;
}

int main(){
    int a = exe();
    cout << a << endl;
    return 0;
}
/*
输出:
exe begin
exe end
async begin
(睡眠五秒...)
async end
200
*/

我们可以看到,启用了异步线程,并且有future对象做为返回值,所以没有阻碍临时对象的析构。同时我们没有对future对象用使用get来阻塞主线程。但是虽然exe end被打印,但是因为满足上面的条件,所以该future对象的析构函数被阻塞了,这个调用线程依旧会被阻塞。所以这个函数的执行没有结束,所以main函数内的函数调用不会返回。所以调用线程依旧会等待异步线程结束而结束。

  • 这种阻塞行为可以理解为该future临时对象的析构函数是对底层异步执行任务的线程实施了一次隐式的join –emc++ 条款38
    • 在future的析构函数不被阻塞时(不满足上述条件的时候),可以理解为对底层线程实施了一次隐式的detach

future, packaged_tast, promise三者的关系

std::future用于访问异步操作的结果,而std::promise和std::packaged_task包住了future,它们内部都有一个future,promise包装的是一个值,packaged_task包装的是一个函数(异步操作本身),当需要获取线程中的某个值,可以使用std::promise,当需要获取线程函数返回值,可以使用std::packaged_task。

这三个东西和async都是互相搭配使用的。具体在哪个线程阻塞,哪个线程获取,哪个先哪个后,包括是传引用还是move都是依照情况而定的。

  • packaged_task ≈ promise + function
  • async ≈ thread + packaged_task
  • 通过promise的get_future()可拿到future
  • 通过future的share()可拿到shared_future

v2-4ef189e8c5a3473494ab9855002b312d_r

https://www.zhihu.com/question/547132461/answer/2657296340

杂项

  • std::future提供访问异步操作结果的机制。std::future 只能与指定事件相关联,而 std::shared_future 就能关联多个事件。future对象本身并不提供同步访问(需要使用get/wait)。future的get()函数的设计包含移动语义,即只能调用一次,第二次调用时会报异常。shared_future的get()函数的设计包含复制语义,可以多次调用。std::shared_future对象可以通过std::future对象隐式转换,也可以通过显示调用std::future::share显示转换,在这两种情况下,原std::future对象都将变得无效。
  • 当不着急让任务结果时,可以使用 std::async 启动一个异步任务。std::async 会返回一个 std::future 对象。get()等价与先调用wait()再调用get()。 std::launch::defered 表明函数调用延迟到wait()或get()函数调用时才执行,std::launch::async 表明函数必须在其所在的独立线程上执行。
  • std::packaged_task<> 会将future与函数或可调用对象进行绑定。当 std::packaged_task 作为函数调用时,实参将由函数调用操作符传递至底层函数,并且返回值作为异步结果存储在 std::future 中。
  • std::promise/std::future 对提供一种机制:future可以阻塞等待线程,提供数据的线程可以使用promise对相关值进行设置,并将future的状态置为“就绪”。
  • 任何情况下,当future的状态还不是“就绪”时,调用 std::promise 或 std::packaged_task 的析构函数,将会存储一个与 std::future_errc::broken_promise 错误状态相关的 std::future_error 异常。
    • 当调用抛出一个异常时,这个异常就会存储到future中,之后调用get()会抛出已存储的异常。
    • std::current_exception() 来检索抛出的异常,可用 std::copy_exception() 作为替代方案, std::copy_exception() 会直接存储新的异常而不抛出。
  • 因为 std::future 是只移动的,所以其所有权可以在不同的实例中互相传递,但只有一个实例可以获得特定的同步结果,而 std::shared_future 实例是可拷贝的,所以多个对象可以引用同一关联期望值的结果。
  • 当多线程 在没有额外同步的情况下,访问一个独立的 std::future 对象时,就会有数据竞争和未定义的 行为。这是因为: std::future 模型独享同步结果的所有权,并且通过调用get()函数,一次性 的获取数据,这就让并发访问变的毫无意义——只有一个线程可以获取结果值,因为在第一 次调用get()后,就没有值可以再获取了。
  • 在每一个 std::shared_future 的独立对象上成员函数调用返回的结果还是不同步的,所以为 了在多个线程访问一个独立对象时,避免数据竞争,可以使用两种方式:
    • 每一个线程有自己的shared_future对象,然后都通过自己的这个shared_future对象来获取结果。

async future搭配数组进行多线程accumulate的小例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct custom_acc{
    int operator()(int a, future<int>& b){ //使用自定义累加。注意第一个参数是int。因为是把第二个参数作用域第一个参数上
        //其次,这里future不要忘记指定模板参数。
        //第三,这里future对象不可设置为const。因为get参数会更改future的内部状态。所以get不是const函数。const对象不能调用非const函数
        return a + b.get();//使用get取出计算结果。
    }
};

int acc_calc(std::vector<int>::iterator begin_iter, std::vector<int>::iterator end_iter){
    return std::accumulate(begin_iter, end_iter,0); //这里老规矩。和async解析一样。任务函数。
}

int main(){
    vector<int>my_vec(2000, 3); //要计算的数组
    vector<future<int>> my_futures; //装有future对象的数组
    int curpos = 0;
    for(int i = 0; i < 10; i++){
        my_futures.emplace_back(std::async(std::launch::async, acc_calc, my_vec.begin()+curpos, my_vec.begin()+curpos+200));
        //这里我们在future对象数组内原地构造一个async对象。async对象传入启动方式,任务函数,和函数参数。
        curpos+=200;
    }
    int ret = accumulate(my_futures.begin(), my_futures.end(), 0, custom_acc()); //这里我们逐个取出future对象数组并使用我们自定义的方法进行累加。
    cout << ret << endl;
    return 0;
}

atomic

https://youtu.be/ZQFzMfHIxng

无锁编程不一定会让程序更快,甚至会有副作用。它并不具有加速行为。

无锁编程不保证性能,不保证程序更快。

证明一个无锁代码的正确性非常难,如果必须使用无锁,最好使用库而不要自己搓轮子。

基本上如果架构没有太大问题,整个系统的瓶颈不会出现在某个锁上,换句话说,出现因为锁的性能瓶颈时大都是因为架构的问题,很多时候根本没必要上lock-free,一般的同步手段完全可以满足我们的需求。但是因为这样我们就可以不了解这个问题吗?我想作为一个IT行业未来的从业者不应该这样想,我们应该很清楚我们的代码经过了哪些变化,指令重排有时会对代码造成影响。

打算使用无锁编程提升性能之前,先检查其他部分,比如算法。

std::atomic_flag

std::atomic_flag 是原子布尔类型。不同于所有 std::atomic 的特化,它保证是免锁的。不同于 std::atomic<bool>std::atomic_flag 不提供加载或存储操作。

std::atomic_flag是最简单的原子类型,这个类型的对象可以在两个状态间切换:

  • 设置

  • 清除

默认构造函数

构造一个新std::atomic_flag对象,不过未指明状态。这里未指定默认构造出来的std::atomic_flag实例是clear状态,还是set状态(c++20后默认构造函数初始化的状态为clear)。因为对象存储过程是静态的,所以初始化必须是静态的。std::atomic_flag 必须使用ATOMIC_FLAG_INIT进行初始化,这样构造出来的实例状态为clear另外,atomic_flag不能被拷贝,也不能 move

std::atomic_flag::test_and_set

原子地更改 std::atomic_flag的状态为设置( true )并返回它先前保有的值。

std::atomic_flag::clear

原子地更改 std::atomic_flag的状态为清除( false )。

std::atomic_flag::wait/notify_one/notify_all

C++20内容

std::atomic

std::atomic模板的每个实例化和专门化都定义了一个原子类型。如果一个线程在另一个线程读取它时写入一个原子对象,那么行为就会被明确定义(参见关于数据竞争的详细信息的内存模型)。此外,对原子对象的访问可以建立线程间的同步,并按照std::memoryorder指定非原子性的内存访问。

  • std::atomic可以用任何简单的可复制的t实例化。同时std::atomic不可复制的,不可移动的。

  • 注意初始化方式只能使用直接初始化。因为拷贝赋值和拷贝构造被禁用且不提供移动构造和移动赋值。这个初始化方式非原子

    • c++17后可以使用拷贝初始化。强制使用了复制省略技术。前提是右侧必须是prvalue临时对象
    • atomic不可以使用聚合初始化(aggregate initialization)
    1
    2
    
    atomic<int> a = 5; //c++14错误, c++17 OK
    atomic<int> a(5);
    
  • 具体特化类型参考https://zh.cppreference.com/w/cpp/atomic/atomic

  • 特化成员函数操作,操作符重载加减之类的不讲了。

  • 注意原子操作没有乘法和除法

  • 注意带有赋值操作符的时候不能保证整条语句的原子性。
    1
    2
    3
    4
    
    atomic<int> a(5);
    a = a + 5; //只保证读取a和a+5并且赋值分别是原子的。也就是原子读 + 原子写。不能保证原子读+写。
     //也就是此时在原子操作:把A读出来 和原子操作:把A+5然后写回A 之间,任何指令都可以插入。
     //可能存在把A读出来,然后其他操作把A修改为100000,然后A被修改为A+5然后写回。这样的情况。
    
  • c++20前,浮点类型不支持自增操作。

  • std::atomic 模板可用任何满足可复制构造 (CopyConstructible) 可复制赋值 (CopyAssignable) 可平凡复制 (TriviallyCopyable) 类型 T 特化。若下列任何值为 false 则程序为非良构:

  • 针对原子对象的操作并不一定全是“原子”的。也就是不一定保证无锁。需要看内存是否对齐了。可以使用is_lock_free()进行判断针对该原子对象的操作是否是无锁的。详细查看这个视频

std::atomic::store

1
void store( T desired, std::memory_order order = std::memory_order_seq_cst ) noexcept;

原子地val 替换当前值。按照 order 的值影响内存。order 必须是 std::memory_order_relaxedstd::memory_order_releasestd::memory_order_seq_cst 之一。否则行为未定义。

1
2
atomic<T> x;
x.store(y); //次操作在含以上等同于x = y;

std::atomic::load

1
T load( std::memory_order order = std::memory_order_seq_cst ) const noexcept;

原子地加载并返回原子变量的当前值。按照 order 的值影响内存。

order 必须是 std::memory_order_relaxedstd::memory_order_consumestd::memory_order_acquirestd::memory_order_seq_cst 之一。否则行为未定义。

1
2
atomic<T> x;
T y = x.load(); //此操作在含义上等同于 T y = x;

std::atomic::operator=

等于store() 注意用法。

1
2
atomic<int> a;
a = 3;

这个等号不是拷贝赋值的那个等号。

std::atomic::operator T

等于load()

std::atomic::exchange

1
T exchange( T desired, std::memory_order order = std::memory_order_seq_cst )

原子地以 desired 替换底层值。操作为读-修改-写操作。根据 order 的值影响内存。

返回调用前原子对象的值。

1
2
atomic<T> x;
T z = x.exchange(y); //此操作在含义上等同 z = x; x = y;

std::atomic::compare_exchange_weak/strong

上面的exchange只是原子交换。但是这个就是著名的CAS

1
2
3
4
5
6
bool compare_exchange_weak( T& expected, T desired,
                            std::memory_order success,
                            std::memory_order failure ) noexcept;
bool compare_exchange_strong( T& expected, T desired,
                              std::memory_order success,
                              std::memory_order failure ) noexcept;
  • 原子地比较 *this expected 的对象表示,而若它们逐位相等,则以 desired 替换前者(进行读修改写操作)。否则,将 *this 中的实际值加载进 expected (进行加载操作)
    • 若成功更改底层原子值则返回 true ,否则为 false
  • 归纳一下这个函数的使用要点:

    • 当前值与期望值(expect)相等时,修改当前值为设定值(desired),返回true
    • 当前值与期望值(expect)不等时,将期望值(expect)修改为当前值,返回false
      • 这一行非常关键。如果当前值和期望值不相等,会修改期望值而不是修改当前值。所以下面我们实现简单的自旋锁的时候必须要搭配while,而且while内需要把值改回去。
    • 这个函数可能在满足true的情况下仍然返回false,所以只能在循环里使用,否则可以使用它的strong版本
      • weak版和strong版的区别:
        • weak版本的CAS允许偶然出乎意料的返回(比如在当前值和期待值一样的时候却返回了false),不过在一些循环算法中,这是可以接受的。通常它比起strong有更高的性能。
        • 如果此事偶然发生,可以对期望值(expect)进行检查。期望值应该不会改变。
        • 用人话说就是compare_exchange_weak 有可能在当前值与 expected 相等时仍然不执行交换并返回 false; compare_exchange_strong 则不会有这个问题. weak 版本能让编译器在一些平台下生成一些更优的代码, 在 x86 下是没区别的.
1
2
3
4
5
atomic<T> x;
bool success = x.compare_exchange_weak(y, z);
//此操作含义为:
//如果x == y, 则让 x = z 并且返回true
//如果x != y, 则让 y = x 并且返回false

有没有注意到为啥这个函数有俩memory_order?

QQ截图20230116025214

为了支持指定两个内存顺序: 成功时的内存顺序和失败时的内存顺序. 一个用于读取一个用于写入

在 x86 下 compare_exchange_* 会被编译成一条 cmpxchgl 指令, 因此操作是原子且无锁的.

https://blog.csdn.net/feikudai8460/article/details/107035480

https://luyuhuang.tech/2022/10/30/lock-free-queue.html

额外说一下fetch系列操作。这里就说一下fetch_add

指针特化的fetch_add在下面。这里针对普通类型。

1
2
T fetch_add( T arg,
             std::memory_order order = std::memory_order_seq_cst ) noexcept;
  • 原子地以值和 arg 的算术加法结果替换当前值。运算是读修改写操作。按照 order 的值影响内存。
  • 其实就是 +=
  • 但是返回值是原值!!!!!!
1
2
3
4
atomic<int> x(200);
int z = x.fetch_add(20); //z = x; x += y。
cout << z << endl; //z是x修改前的值。200
cout << x.load() << endl; //x是220

std::atomic<T*>特化

  • 针对指针类型的特化,代表指针指向的对象不是原子的。而是代表这个指针本身是原子的。
  • 同样不可拷贝构造和拷贝赋值。
  • 但是他可以通过合适的指针类型(不一定非得是原子的)进行构造和赋值。

针对指针特化类型的特殊操作

特化的函数这里主要简单介绍fetch_add, fetch_sub, operator++, operator--

自增自减没啥好说的。这里就举个fetch_add的例子

  • fetch_add

    • 1
      2
      
      T* fetch_add( std::ptrdiff_t arg,
                    std::memory_order order = std::memory_order_seq_cst ) noexcept;
      
    • 原子地以值和 arg 的算术加法结果替换当前值。运算是读修改写操作。按照 order 的值影响内存。

    • 其实就是+=操作。注意这个加法是指针加法。

std::atomic::wait/notify_one/notify_all

C++20内容

atomic 支持操作总结

操作atomic_flagatomic<bool>atomic<T*>atomic<integral_type>atomic<other_type>
test_and_set    
clear    
is_lock_free 
load 
store 
exchange 
compare_exchange_weak/strong 
fetch_add, +=   
fetch_sub, -=   
fetch_or, |=    
fetch_and, &=    
fetch_xor, ^=    
++,–   
  • 关于用户定义类型,有非常严格要求。建议去官方文档看。

修改顺序, 指令重排,缓存一致性,内存屏障和内存模型

修改顺序

对一个原子变量的所有修改操作总是存在一定的先后顺序, 且所有线程都认可这个顺序, 即使这些修改操作是在不同的线程中执行的. 这个所有线程一致同意的顺序就称为修改顺序 (modification order). 这意味着

  • 两个修改操作不可能同时进行, 一定存在一个先后顺序. 这很容易理解, 因为这是原子操作必须保证的, 否则就有数据竞争的问题.
  • 即使每次运行的修改顺序可能都不同, 但所有线程看到的修改顺序总是一致的. 如果线程 a 看到原子变量 x 由 1 变成 2, 那么线程 b 就不可能看到 x 由 2 变成 1.

无论使用哪种内存顺序, 原子变量的操作总能满足修改顺序一致性, 即使是最松散的 memory_order_relaxed. 我们来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
std::atomic<int> a{0};

void thread1() {
    for (int i = 0; i < 10; i += 2)
        a.store(i, std::memory_order_relaxed);
}

void thread2() {
    for (int i = 1; i < 10; i += 2)
        a.store(i, std::memory_order_relaxed);
}

void thread3(vector<int> *v) {
    for (int i = 0; i < 10; ++i)
        v->push_back(a.load(std::memory_order_relaxed));
}

void thread4(vector<int> *v) {
    for (int i = 0; i < 10; ++i)
        v->push_back(a.load(std::memory_order_relaxed));
}

int main() {
    vector<int> v3, v4;
    std::thread t1(thread1), t2(thread2), t3(thread3, &v3), t4(thread4, &v4);
    t1.join(), t2.join(), t3.join(), t4.join();

    for (int i : v3) cout << i << " ";
    cout << endl;
    for (int i : v4) cout << i << " ";
    cout << endl;
    return 0;
}

上面的代码创建了 4 个线程. thread1thread2 分别将偶数和奇数依次写入原子变量 a, thread3thread4 则读取它们. 最后输出 thread3thread4 每次读取到的值. 程序运行的结果可能是这样的

1
2
3
4
5
1 8 7 7 7 9 9 9 9 9
0 2 8 8 8 7 9 9 9 9
-------------------
1 2 5 6 9 9 9 8 8 8
1 3 2 5 9 8 8 8 8 8

虽然每次运行的修改顺序不同, 各个线程也不太可能看到每次修改的结果, 但是它们看到的修改顺序是一致的. 例如 thread3 看到的顺序是 8 7 9, 那么thread4看到的顺序也是8 7 9. thread3看到的顺序是1 5 9 8 那么 thread4看到的顺序一样是1 5 9 8

指令重排

Happens-Before 关系

A、B 是两个在多核 CPU 上执行的操作。如果 A happens-before B,那么 A 所产生的内存变化会在 B 操作执行之前被看到(visible)。

简而言之, 如果操作 a “happens-before” 操作 b, 则操作 a 的结果对于操作 b 可见. happens-before 的关系可以建立在用一个线程的两个操作之间, 也可以建立在不同的线程的两个操作之间.

不管我们使用什么编程语言,在同一个线程下的顺序语句总是遵循 happens-before 原则的。

就像下面代码所示:

1
2
3
4
5
6
7
8
int a, b;

void foo() {
    a = 42;
    b = a;

    assert(b == 42);
}

在单线程的情况下,断言是永远不会为假的。(不然这还怎么写程序…)

但这并不代表 b 在内存中后于 a 被修改。就像下面这段伪代码所示的:

1
2
3
mov %eax, 42
mov (b), %eax
mov (a), %eax

虽然这并不能说明编译器就是这么处理的,但足以说明程序语义上的 happens-before 不能代表操作是真的 happened before 了。

Happens-before 的第一种场景: sequenced before(单线程)

单线程的情况很容易理解. 函数的语句按顺序依次执行, 前面的语句先执行, 后面的后执行. 正式地说, 前面的语句总是 “sequenced-before” 后面的语句. 显然, 根据定义, sequenced-before 具有传递性:

  • 如果操作 a “sequenced-before” 操作 k, 且操作 k “sequenced-before” 操作 b, 则操作 a “sequenced-before” 操作 b.

Sequenced-before 可以直接构成 happens-before 的关系. 如果操作 a “sequenced-before” 操作 b, 则操作 a “happens-before” 操作 b. 这是最基本的场景:

1
2
a = 42; // (1)
cout << a << endl; // (2)

语句 (1) 在语句 (2) 的前面, 因此语句 (1) “sequenced-before” 语句 (2), 也就是 (1) “happens-before” 语句 (2). 所以 (2) 可以打印出 (1) 赋值的结果且没有任何问题

Happens-before 的第二种场景: synchronizes-with 和 inter-thread happens-before(多线程)

一般来说, 如果在多个线程间没有正确的同步操作, 就无法保证两个操作之间有 happens-before 的关系. 如果我们通过一些手段, 让不同线程的两个操作同步, 我们称这两个操作之间有 synchronizes-with 的关系. 稍后我们会详细讨论如何组合使用 6 种内存顺序, 让两个操作达成 synchronizes-with 的关系.

如果线程 1 中的操作 a “synchronizes-with” 线程 2 中的操作 b, 则操作 a “inter-thread happens-before” 操作b. 此外 synchronizes-with 还可以 “后接” 一个 sequenced-before 关系组合成 inter-thread happens-before 的关系:

  • 如果操作 a “synchronizes-with” 操作 k, 且操作 k “sequenced-before” 操作 b, 则操作 a “inter-thread happens-before” 操作 b.

Inter-thread happens-before 关系则可以 “前接” 一个 sequenced-before 关系以延伸它的范围; 而且 inter-thread happens-before 关系具有传递性:

  • 如果操作 a “sequenced-before” 操作 k, 且操作 k “inter-thread happens-before” 操作 b, 则操作 a “inter-thread happens-before” 操作 b.
  • 如果操作 a “inter-thread happens-before” 操作 k, 且操作 k “inter-thread happens-before” 操作 b, 则操作 a “inter-thread happens-before” 操作 b.

正如它的名字暗示的, 如果操作 a “inter-thread happens-before” 操作 b, 则操作 a “happens-before” 操作 b. 下图展示了这几个概念之间的关系:

image-20240610001317806

注意, 虽然 sequenced-before 和 inter-thread happens-before 都有传递性, 但是 happens-before 没有传递性.

例子1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int data;
std::atomic_bool flag { false };

// Execute in thread A
void producer() {
    data = 42;  // (1)
    flag.store(true);  // (2)
}

// Execute in thread B
void consume() {
    while (!flag.load());  // (3)
    assert(data == 42);  // (4)
}

让两个函数分别在两个线程中运行,(4) 所示的断言将有一定几率 为假,这是为什么呢?虽然我们使用原子量 flag 作为“同步信号”,而且同一个线程中 happens-before 原则也一定会被遵循,但我们并不能保证 (4) 执行时 (1) 的修改一定会被 B 线程看到, 可能是2-3-4-1这个顺序。这是由于现代处理器对于程序可能会采取指令重排来提高运行效率,CPU 的读写 Cache 也可能并没有写回内存。

所以,企图在多线程环境中通过某原子量来做非原子量的 Synchronization 并不是可靠的(当没有 Memory Order 的约束时候)。

当然,上面的代码其实基本上不会为假,因为 C++ 默认使用 memory_order_seq_cst顺序约束。而且 x86 架构中也做不到松弛(memory_order_relaxed)内存模型。也就是说,这种过于松弛的模型理论上存在,但其实不会发生。因为如果允许这个级别的松弛那么代码没法写了。

例子2, 假设下面的代码中 unlock() 操作 “synchronizes-with” lock() 操作:

1
2
3
4
5
6
7
8
9
void thread1() {
    a += 1 // (1)
    unlock(); // (2)
}

void thread2() {
    lock(); // (3)
    cout << a << endl; // (4)
}

假设直到 thread1 执行到 (2) 之前, thread2 都会阻塞在 (3) 处的 lock() 中. 那么可以推导出:

  • 根据语句顺序, 有 (1) “sequenced-before” (2) 且 (3) “sequenced-before” (4);
  • 因为 (2) “synchronizes-with” (3) 且 (3) “sequenced-before” (4), 所以 (2) “inter-thread happens-before” (4);
  • 因为 (1) “sequenced-before” (2) 且 (2) “inter-thread happens-before” (4), 所以 (1) “inter-thread happens-before” (4); 所以 (1) “happens-before” (4).

因此 (4) 可以读到 (1) 对变量 a 的修改

在上面的例子中,我们主要讲述的是指令重排。但是内存屏障不仅仅是为了防止指令重排,也会解决缓存一致性问题。在最后我们会详细探讨这其中的关系。

缓存一致性

我们都知道二维数组横向遍历比纵向遍历要快。为什么?

  • 我们知道有L1 L2 L3三级cache。然后才是内存。CPU需要把内存里的数据读到cache中。但是CPU为了最大化利用cache line的能力,一般会读取一个固定大小的数据区块。一般来说 L3 Cache 比 L1 Cache 和 L2 Cache 大很多,这是因为 L1 Cache 和 L2 Cache 都是每个 CPU 核心独有的,而 L3 Cache 是多个 CPU 核心共享的。

这也就是为什么连续内存操作会比较快,也是为什么内存对齐十分重要。因为如果起点在奇数位就会有二次操作。

CPU cache到内存的映射有三种:

  • 直接映射
    • 直接映射通俗易懂。就是内存地址映射到的cache line是固定的。比较好找。但是问题在于这个数量非常少。如果多个数据抢占同一个cache line,就会发生cache频繁的换入换出。
  • 全相连映射
    • 全相连映射的意思是任意一个内存地址可以映射到任意一个cache line。也就是见缝插针。问题在于在寻找一个内存地址是否已被映射的时候,需要遍历每一个cacheline
  • 组相连映射
    • 组相连映射也就是组间采用直接映射,组内采用全相连映射。

CPU cache读取过程

假设我们的L1 Cache Line是64字节:

比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU 就会顺序加载数组元素到 array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。

事实上,CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据。

我们刚提到过直接映射会把内存的固定地址映射到固定的cache line。一般来说是通过取模运算来确定的。所以会存在多个内存区块映射到同一个cache line位置上。这时候就要区分他们了。为了区别不同的内存块,在对应的 CPU Line 中我们还会存储一个组标记(Tag)。这个组标记会记录当前 CPU Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。

除了组标记信息外,CPU Line 还有两个信息:

  • 一个是,从内存加载过来的实际存放数据(Data)
  • 另一个是,有效位(Valid bit),它是用来标记对应的 CPU Line 中的数据是否是有效的,如果有效位是 0,无论 CPU Line 中是否有数据,CPU 都会直接访问内存,重新加载数据。

CPU 在从 CPU Cache 读取数据的时候,并不是读取 CPU Line 中的整个数据块,而是读取 CPU 所需要的一个数据片段,这样的数据统称为一个字(*Word*)。那怎么在对应的 CPU Line 中数据块中找到所需的字呢?答案是,需要一个偏移量(Offset)

因此,一个内存的访问地址,包括组标记、CPU Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成。

QQ截图20230114224502

如果内存中的数据已经在 CPU Cahe 中了,那 CPU 访问一个内存地址的时候,会经历这 4 个步骤:

  1. 根据内存地址中索引信息,计算在 CPU Cahe 中的索引,也就是找出对应的 CPU Line 的地址;
  2. 找到对应 CPU Line 后,判断 CPU Line 中的有效位,确认 CPU Line 中数据是否是有效的,如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行;
  3. 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据,如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行;
  4. 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。

问题在于,我们并不是只读数据。我们还会写入。那么如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不一致了,于是我们肯定是要把 Cache 中的数据同步到内存里的。

问题来了,那在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法:

  • 写直达(Write Through

    • 保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(*Write Through*)

    • 在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:

      • 如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;
      • 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。

      写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。

  • 写回(Write Back

    • 既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(*Write Back*)的方法
    • 在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。
    • 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;
    • 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的,如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,写入到这个 Cache Block 里,同时也把它标记为脏的;如果 Cache Block 里面的数据没有被标记为脏,则就直接将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。

    可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。

    这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。

现在有一个问题。我们东西确实是写入L1/L2 cache了。但是L1/L2 cache是每个核心独有的。也就是说,有可能在一个核心修改完数据后,修改的数据还在自己的缓存里。另一个核心从自己的缓存里读就会读到没有修改过的值。

QQ截图20230114224941

  • 这里的意思是有可能r2 = 0 且 r4 = 0。为什么?
    • 我们可能会回答指令重排。但是X86是不允许写-写重排的。所以导致这个情况的就是我们本节的缓存一致性。
    • 但是具体原因是什么呢?我们先看看MESI协议。

那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:

  • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(*Wreite Propagation*)
  • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(*Transaction Serialization*)

第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。而对于第二点事务事的串形化,我们举个例子来理解它。:

  • 假设我们有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。

QQ截图20230114225424

QQ截图20230114225429

那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200

而如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。

所以,我们要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。

要实现事务串形化,要做到 2 点:

  • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
  • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。

MESI 协议

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

这四个状态来标记 Cache Line 四个不同的状态。

  • 「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。
  • 「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
  • 「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。
    • 「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
    • 另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
  • 那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。

我们举个具体的例子来看看这四个状态的转换:

  1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
  2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
  3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
  4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

说完了MESI协议,回到我们Example 8.5的那张图。具体缓存一致性在哪儿出问题了?

假设在cache line中状态为共享,在某个核更新数据时候会向总线发送已失效消息以获取数据的所有权,其他消息得到此消息后更新相应cache line状态为已失效,并回复Invalidate Acknowledge消息。显然我们可以看到这个过程非常的繁琐,如果每个操作生效前都需要这么多操作那也太低效了,所以引入了Invaildate queueStore Buffer,为的就是异步执行指令,提高以上过程的效率。

QQ截图20230114230210

因为写入到Invaildate queueStore Buffer的数据并不会被立即执行。在执行写操作的核会把数据写入store buffer,后面异步的刷新到cache line,当然也会广播失效消息。而当另一个执行读操作的核收到这个失效消息时,会把消息写入自身的Invalidate Queue中,随后异步将cache line设为失效状态。写操作的核在读取的时候会扫描store buffer,而这个执行读操作的核在读取数据的时候并不会扫描Invaildate queue这意味着读操作可能会在一段时间内读到老旧数据。

首先process 0和process 1把1放入到地址_x_y的内存上,但是此时可能这些数据还存在自己的store buffer中,也就是对方都还没意识到数据已经发生修改,然后执行操作r2r4的操作,此时就发生脏读,导致 r2 = 0 r4 = 0这种奇怪的事情发生。

  • 也就是在processor 1把y的值更改为1的时候,并没有直接写入内存。此时在processor 0眼里y还是0。所以r2就是0

https://mp.weixin.qq.com/s/Sz7QXx1h4sS7xWRUSP4ZCw

内存屏障

经过上面的总结,我们可以得出结论。内存屏障存在的意义是在一定程度上避免缓存一致性所带来的问题和指令重排。

在广义角度上,Intel有三种内存模型:

SFENCE,LFENCE和MFENCE指令提供了一种性能高效的方式,可确保在产生弱排序结果的例程和使用该数据的例程之间load和store内存排序。 这些指令的功能如下:

  • SFENCE:序列化(动词,理解为串行化) 程序指令流中,在SFENCE指令之前发生的所有 store(写)操作,但不影响 load(读)操作。
  • LFENCE:序列化 程序指令流中,在LFENCE指令之前发生的所有 load(读)操作,但不影响 store(写)操作。
  • MFENCE:序列化 程序指令流中,在MFENCE指令之前发生的所有 store 和 load 操作。

当然,还有一些其他操作含有内存屏障的作用

  • 总线上的内存映射设备和其他I / O设备通常对其I / O缓冲区的写入顺序敏感。可以使用I / O指令(IN和OUT指令)对此类访问施加强的写入顺序,如下所示。在执行I / O指令之前,处理器将等待程序中所有先前的指令完成,并等待所有缓冲的写入操作耗尽到内存。只有指令提取和页表遍历可以传递I / O指令。直到处理器确定I / O指令已完成,后续指令的执行才开始。
  • 多处理器系统中的同步机制可能取决于强大的内存排序模型。在这里,程序可以使用诸如XCHG指令或LOCK前缀之类的锁定指令,以确保原子地执行对存储器的读-修改-写操作。锁定操作通常类似于I / O操作,因为它们等待所有先前的指令完成并且等待所有缓冲的写操作排入内存。
  • 程序同步也可以通过序列化指令执行(请参见第8.3节)。这些指令通常用于关键过程或任务边界,以在跳到新的代码段或上下文切换之前强制完成所有先前的指令。像I / O和锁定指令一样,处理器在执行序列化指令之前要等到所有先前的指令都已完成并且所有缓冲的写操作都已排入内存为止。

请注意,与CPUID指令相比,SFENCE,LFENCE和MFENCE指令提供了一种更有效的控制内存顺序的方法。

从上面,我们可以看出:内存屏障,带Lock或XCHG前缀的指令,I/O操作都会把数据载入内存,自然就不存在前面我们提到的脏读问题了。

我们一直以来提到的指令重排指的是运行时排序。编译器也会导致指令重排。这里更多的讨论见这里

内存模型(内存序)

我们前面提到了程序运行时的指令重排,内存模型其实规定的就是这些指令的重排哪些排列顺序是正确的,也就是指明在特定要求下,哪些是可以出现的,哪些是不可以出现的。

事实上对于内存模型的描述我们可以从两个方面来看,一个是硬件角度的内存模型,也就是厂商给我们提供了怎样的一种一致性保证;还有一种是软件级别的内存模型,其实说直白一点就是一些高级语言给程序员提供的一致性保证,显然此时我们可以不必费尽心机去考虑代码如何适配不同的机器。

比如X86的强模型和ARM的弱模型,如果仅仅依靠硬件提供的内存屏障这个代码将非常难写, 因为不同硬件本身的提供的内存模型是不一样的,甚至有时操作也不相同。但是如果基于软件级别的内存模型,比如使用C++的内存模型(手段为内存序),那么编译器就会帮我们自动去适配不同的机器,因为这是语言保证的。

英特尔酷睿2双核,英特尔凌动,英特尔酷睿双核,奔腾4和P6家族处理器还使用处理器排序的内存排序模型,该模型可以进一步定义为“使用存储缓冲区转发进行写入排序”。 该模型的特征如下。 在用于定义为WB的内存区域的单处理器系统中,内存排序模型遵循以下原则(请注意,单处理器和多处理器系统的内存排序原则是从在以下环境中执行软件的角度编写的。其中术语“处理器”是指逻辑处理器。例如,支持多核和/或Intel超线程技术的物理处理器被视为多处理器系统。)

  • 读操作之间无法排序

  • 写操作不能和旧的读操作之间排序

  • 写操作与写操作之间除了以下情况以外不能重新排序:

    • 带有non-temporal move指令的流存储(写入)(MOVNTI, MOVNTQ, MOVNTDQ, MOVNTPS, 和 MOVNTPD)

    • 串操作

  • 执行CLFLUSH指令不能重新排序对存储器的写操作。 可以通过执行CLFLUSHOPT指令的执行来重新排序写操作,该指令将刷新除正在写入的高速缓存行以外的其他高速缓存行。 CLFLUSH指令的执行不会相互重新排序。 访问不同缓存行的CLFLUSHOPT的执行可能会相互重新排序。 CLFLUSHOPT的执行可以与访问不同缓存行的CLFLUSH的执行重新排序。
  • 读取可能会与不同地址的较旧写入重新排序,但对不同地址的较旧写入则不会重新排序。(store load重排)
  • 读写操作不能与IO操作,带LOCK的指令,序列化指令重排序。(上面曾提到过这个问题)
  • 读操作不能越过前面的LFENCE和MFENCE操作
  • 写操作和CLFLUSH和CLFLUSHOPT的执行不能越过前面的LFENCE,SFENCE和MFENCE指令。
  • LFENCE不能越过前面的读操作
  • SFENCE不能越过前面的写操作
  • MFENCE不能越过前面的读写操作以及CLFLUSH和CLFLUSHOPT的执行。

https://zhuanlan.zhihu.com/p/269221065?utm_id=0

https://blog.51cto.com/u_15703183/5464436

https://blog.csdn.net/qq_22642239/article/details/114022306

https://redrain.blog.csdn.net/article/details/111327141

C++中的内存序—-三(四)种模型和六种枚举值

std::memory_order 指定内存访问,包括常规的非原子内存访问,如何围绕原子操作排序。在没有任何制约的多处理器系统上,多个线程同时读或写数个变量时,一个线程能观测到变量值更改的顺序不同于另一个线程写它们的顺序。其实,更改的顺序甚至能在多个读取线程间相异。一些类似的效果还能在单处理器系统上出现,因为内存模型允许编译器变换。

注意,其实内存序并不能简单描述成为禁止某某操作排到某某操作后面。因为就算单纯禁止重排,也有可能发生我们提过的缓存一致性问题。所以说,应该用可见性这个词。可见性这个词包含了描述重排和缓存一致性的情况。所以说,我们说某某操作现在是可见的,表明了确实我们保证操作正确。也就是不仅正确处理了重排,也正确处理了缓存一致性问题。

顺序一致(sequentially consistent ordering)

memory_order_seq_cst

C++使用这个做为操作原子变量的默认值。可以直接看文档。

  • Load/store/RMW操作都可以使用该枚举值,用于 load operation(原子读操作)的时候有acquire operation的特性,用于 store operation(原子写操作)的时候有release operation的特性, 用于 read-modify-write operation(RMW)的时候有acq_rel operation的特性,且所有操作都相当于一个双向屏障,前后语句都不能跨越该操作进行重排并且所有线程的语句都以全局的内存修改顺序为参照
  • 看起来,这个和memory_order_acq_rel差不多啊?并不是。这种内存序列会对拥有此标签的内存操作建立一个单独全序memory_order_seq_cstmemory_order_acq_rel更强,memory_order_acq_rel的顺序保障,是要基于同一个原子变量的,也就是说,在这个原子变量之前的读写,不能重排到这个原子变量之后,同时这个原子变量之后的读写,也不能重排到这个原子变量之前。但是,如果两个线程基于memory_order_acq_rel使用了两个不同的原子变量x1, x2,那在x1之前的读写,重排到x2之后,是完全可能的,在x1之后的读写,重排到x2之前,也是被允许的。然而,如果两个原子变量x1,x2,是基于memory_order_seq_cst在操作,那么即使是x1之前的读写,也不能被重排到x2之后,x1之后的读写,也不能重排到x2之前,也就说,如果都用memory_order_seq_cst,那么程序代码顺序(Program Order)就将会是你在多个线程上都实际观察到的顺序(Observed Order)

  • 简而言之: 在这个模型下, 所有线程看到的所有操作都有一个一致的顺序, 即使这些操作可能针对不同的变量, 运行在不同的线程. 2.1 节中我们介绍了修改顺序 (modification order), 即单一变量的修改顺序在所有线程看来都是一致的. Sequencial consistent 则将这种一致性扩展到了所有变量. 例如
1
2
3
4
5
6
7
8
9
std::atomic<bool> x{false}, y{false};

void thread1() {
    x.store(true, std::memory_order_seq_cst); // (1)
}

void thread2() {
    y.store(true, std::memory_order_seq_cst); // (2)
}

thread1thread2 分别修改原子变量 xy. 运行过程中, 有可能先执行 (1) 再执行 (2), 也有可能先执行 (2) 后执行 (1). 但无论如何, 所有线程中看到的顺序都是一致的. 因此如果我们这样测试这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::atomic<int> z{0};

void read_x_then_y() {
    while (!x.load(std::memory_order_seq_cst)); // (3)
    if (y.load(std::memory_order_seq_cst)) ++z; // (4)
}

void read_y_then_x() {
    while (!y.load(std::memory_order_seq_cst)); // (5)
    if (x.load(std::memory_order_seq_cst)) ++z; // (6)
}

int main() {
    std::thread a(thread1), b(thread2), c(read_x_then_y), d(read_y_then_x);
    a.join(), b.join(), c.join(), d.join();
    assert(z.load() != 0); // (7)
}

(7) 处的断言永远不会失败. 因为 xy 的修改顺序是全局一致的, 如果先执行 (1) 后执行 (2), 则 read_y_then_x 中循环 (5) 退出时, 能保证 ytrue, 此时 x 也必然为 true, 因此 (6) 会被执行; 同理, 如果先执行 (2) 后执行 (1), 则循环 (3) 退出时 y 也必然为 true, 因此 (4) 会被执行. 无论如何, z 最终都不会等于 0.

所有线程会以相同的顺序观察到这两个操作的执行顺序. 所以如果某个线程看到thread1先执行, 那么其余所有线程都会看到thread1先执行. 然而,这并不意味着线程1和线程2的操作之间有任何特定的顺序。具体来说,x.store(true)y.store(true) 的顺序在不同的线程之间是有序的,但在同一线程中没有保证它们的相对顺序。

Sequencial consistent 可以实现 synchronizes-with 的关系. 如果一个 memory_order_seq_cst 的 load 操作在某个原子变量上读到了一个 memory_order_seq_cst 的 store 操作在这个原子变量中写入的值, 则 store 操作 “synchronizes-with” load 操作. 在上面的例子中, 有 (1) “synchronizes-with” (3) 和 (2) “synchronizes-with” (5).

顺序一致模型有一个最大的问题:它的性能差。我们很多时候并不需要对整体的原子操作进行排序。一般都是局部有序。注意到锁和内存屏障的区别。锁一般来说都是局部的,也就是针对某一个区域加锁解锁。但是不同的锁之间往往是没有顺序的。也就是说,锁更像acquire--release模型,而不是seq_cst模型。

获取发布 (release-acquire/consume ordering)

memory_order_consume

  • 类似于memory_order_acquire,也是用于load操作,但更为宽松。针对于该load操作,不允许load之后的有关联(依赖)的操作重排到load之前。

  • memory_order_consume适用于load operation(原子读操作),对于采用此内存序的load operation,我们可以称为consume operation(consume原子读操作),设有一个原子变量M上的consume operation(consume原子读操作),对周围内存序的影响是:

    • 当前线程中该consume operation(consume原子读操作)后的依赖consume operation(consume原子读操作)读取的值的写入或读取操作不能被重排到该consume operation(consume原子读操作)前,其他线程中所有对M的release operation(原子写操作)及其之前的对数据依赖变量的写入都对当前线程从该consume operation(consume原子读操作)开始往后的操作可见

    • 相比较于下面讲的memory_order_acquirememory_order_consume只是阻止了之后有依赖关系的重排。绝大部分平台上,这个内存序只会影响到编译器优化,依赖于dependency chain。但实际上很多编译器都没有正确地实现consume,导致等同于acquire。

    • 见下图,如果我们把memory_order_acquire换成了memory_order_consume,那么将只有int r2 = x->i 是有效的,因为其读取的值依赖原子变量p,但int r1 = A并不能保证读到线程1写入到A的值,因为A值并不依赖p

QQ截图20220909144257

memory_order_acquire —- 理解为 acquire lock

  • 针对于该load操作,不允许load之后的操作重排到load之前。
  • 说人话就是:单向加载内存屏障,表示线程中的读写指令不能重排到此屏障指令之前,另一个执行原子变量的线程里写操作之前的变量,可以被此线程 读取
    • 理解为lock()。也就是lock之后的代码不能放到lock之前。理解为我们acquire其他线程publish的操作。
  • memory_order_acquire适用于load operation(原子读操作),对于采用此内存序的load operation(原子读操作),我们可以称为acquire operation,设有一个原子变量M上的acquire operation(原子读操作),对周围内存序的影响是:
    • 当前线程中该acquire operation(原子读操作)后的任何内存读写操作都不能被重排到该acquire operation(原子读操作)前。
    • 结合下面的memory_order_release我们能推导出从而会有其他线程中所有对M的release operation(原子写操作)及其之前的写入都对当前线程从该acquire operation(原子读操作)开始往后的操作可见。

QQ截图20230114235531

QQ截图20230115005233

此时在一个线程上执行时,读取x的内存屏障操作之前的指令允许重排到x之后,但是读取x之后的指令不会被重排到x前面。

实际上memory_order_release用于写入、memory_order_acquire用于读取,他们是成对使用:线程A使用memory_order_release写原子变量x,线程B使用memory_order_acquire读原子变量x。线程A写x之前的操作,都可以被线程B在读x之后看到

memory_order_release —- 理解为 release unlock

  • 针对于该store操作,不允许store之前的操作重排到store之后。
  • 说人话就是:单向释放内存屏障,表示线程中的读写指令不能重排到此原子变量指令之后。另一个执行原子变量的线程,可以正确读取指令之前的变量
    • 理解为unlock()。也就是unlock前面的代码不能放到unlock后面。理解为我们把我们对内存的更改 release掉。也就是publish给其他线程
    • memory_order_release适用于store operation(原子写操作),对于采用此内存序的写入操作,我们可以称为release operation,设有一个原子变量M上的release operation(写入操作),对周围内存序的影响是:
      • release operation(写入操作)前的内存读写都不能重排到该release operation(写入操作)之后。(该store操作,不允许store之前的操作重排到store之后。)结合memory_order_acquire的左右从而有:

        • 当前线程截止到该release operation(原子写操作)的所有内存写入都对另外线程对M的acquire operation(原子读操作)以及之后的内存操作可见,这就是release acquire 语义。
        • 当前线程截止到该operation的所有M所依赖的内存写入都对另外线程对M的consume operation以及之后的内存操作可见,这就是release consume语义。

QQ截图20230114234826

QQ截图20230115005203

此时在一个线程上执行时,写入x的内存屏障操作之后的指令允许重排到x之前,但是写入x之前的指令不会被重排到x后面。 不过写入x指令前后的那些指令的顺序是允许重排的。所以使用memory_order_release屏障后,可以保障另一个线程在执行了读取x操作之后,读取abc的值是正确的,因为abc的写入操作一定不会被重排到x操作之后

memory_order_acq_rel

  • 双向的”加载-释放”内存屏障。
  • 多用于CAS操作。比如compare_exchange_weak/strong, fetch_add/sub/...
  • 用于RMW(read-modify-write)原子操作,RMW操作前后的语句都不允许跨越该操作而重排。该操作相当于兼具load(acquire)和store(release),可以看作由这两个操作组成,但是整体上是原子的。
  • memory_order_acq_rel适用于read-modify-write operation(RMW操作),对于采用此内存序的read-modify-write operation,我们可以称为acq_rel operation,既属于acquire operation 也是release operation. 设有一个原子变量M上的acq_rel operation:自然的,因为同时具有两种属性,所以该acq_rel operation之前的内存读写都不能重排到该acq_rel operation之后,该acq_rel operation之后的内存读写都不能重排到该acq_rel operation之前. 其他线程中所有对M的release operation(写入操作)及其之前的写入都对当前线程从该acq_rel operation开始的操作可见,并且截止到该acq_rel operation的所有内存写入都对另外线程对M的acquire operation(原子读操作)以及之后的内存操作可见。
  • 注意,针对内存屏障,多线程之间必须要使用的是同一个原子变量。因为是使用这一个原子变量的值进行同步的。这里和顺序一致模型有区别

release-acquire 可以实现 synchronizes-with 的关系. 如果一个 acquire 操作在同一个原子变量上读取到了一个 release 操作写入的值, 则这个 release 操作 “synchronizes-with” 这个 acquire 操作. 我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
std::atomic<bool> x{false}, y{false};

void thread1() {
    x.store(true, std::memory_order_relaxed); // (1)
    y.store(true, std::memory_order_release); // (2)
}

void thread2() {
    while (!y.load(std::memory_order_acquire)); // (3)
    assert(x.load(std::memory_order_relaxed)); // (4)
}

在上面的例子中, 语句 (2) 使用 memory_order_releasey 中写入 true, 语句 (3) 中使用 memory_order_acquirey 中读取值. 循环 (3) 退出时, 它已经读取到了 y 的值为 true, 也就是读取到了操作 (2) 中写入的值. 因此有 (2) “synchronizes-with” (3). 所以我们可以推导出:

  • 因为 (2) “synchronizes-with” (3) 且 (3) “sequenced-before” (4), 所以 (2) “inter-thread happens-before” (4);
  • 因为 (1) “sequenced-before” (2) 且 (2) “inter-thread happens-before” (4), 所以 (1) “inter-thread happens-before” (4);

所以 (1) “happens-before” (4). 因此 (4) 能读取到 (1) 中写入的值, 断言永远不会失败. 即使 (1) 和 (4) 用的是 memory_order_relaxed.

我们提到 sequencial consistent 模型可以实现 synchronizes-with 关系. 事实上, 内存顺序为 memory_order_seq_cst 的 load 操作和 store 操作可以分别视为 acquire 操作和 release 操作. 因此对于两个指定了 memory_order_seq_cst 的 store 操作和 load 操作, 如果后者读到了前者写入的值, 则前者 “synchronizes-with” 后者.

为了实现 synchronizes-with 关系, acquire 操作和 release 操作应该成对出现. 如果 memory_order_acquire 的 load 读到了 memory_order_relaxed 的 store 写入的值, 或者 memory_order_relaxed 的 load 读到了 memory_order_release 的 store 写入的值, 都不能实现 synchronizes-with 的关系.

虽然 sequencial consistent 顺序一致模型能够像 release-acquire 一样实现同步, 但是反过来 release-acquire 模型不能像 sequencial consistent 一样提供全局顺序一致性. 如果将顺序一致一节例子中的 memory_order_seq_cst 换成 memory_order_acquirememory_order_release

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void thread1() {
    x.store(true, std::memory_order_release); // (1)
}

void thread2() {
    y.store(true, std::memory_order_release); // (2)
}

void read_x_then_y() {
    while (!x.load(std::memory_order_acquire)); // (3)
    if (y.load(std::memory_order_acquire)) ++z; // (4)
}

void read_y_then_x() {
    while (!y.load(std::memory_order_acquire)); // (5)
    if (x.load(std::memory_order_acquire)) ++z; // (6)
}

则最终不能保证 z 不为 0. 在同一次运行中, read_x_then_y 有可能看到先 (1) 后 (2), 而 read_y_then_x 有可能看到先 (2) 后 (1). 这样有可能 (4) 和 (6) 的 load 的结果都为 false, 导致最后 z 仍然为 0.

release-acquire 的开销比 sequencial consistent 小. 在 x86 架构下, memory_order_acquirememory_order_release 的操作不会产生任何其他的指令, 只会影响编译器的优化: 任何指令都不能重排到 acquire 操作的前面, 且不能重排到 release 操作的后面; 否则会违反 release-acquire 的语义. 因此很多需要实现 synchronizes-with 关系的场景都会使用 release-acquire

宽松(relaxed ordering )

memory_order_relaxed

memory_order_relaxed 可以用于 store, load 和 read-modify-write 操作

  • 这种模型下, 只能保证操作的原子性和修改顺序 (modification order) 一致性, 无法实现 synchronizes-with 的关系. 对于其它读写操作没有任何同步和重排的限制,仅要求保证读写的原子性和内存一致性。除此之外,不提供任何跨线程的同步。
  • 一般应用于计数器场景
1
2
3
4
5
6
// Thread 1:
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B
// Thread 2:
r2 = x.load(std::memory_order_relaxed); // C 
y.store(42, std::memory_order_relaxed); // D

执行完上面的程序,可能出现r1 == r2 == 42。理解这一点并不难,因为编译器允许调整 C 和 D 的执行顺序。如果程序的执行顺序是 D -> A -> B -> C,那么就会出现r1 == r2 == 42

1
2
3
4
5
6
std::atomic<bool> x{false}, y{false};

void thread1() {
    x.store(true, std::memory_order_relaxed); // (1)
    y.store(true, std::memory_order_relaxed); // (2)
}

thread1 对不同的变量执行 store 操作. 那么在某些线程看来, 有可能是 x 先变为 true, y 后变为 true; 另一些线程看来, 又有可能是 y 先变为 true, x 后变为 true. 如果这样测试这段代码:

1
2
3
4
void thread2() {
    while (!y.load(std::memory_order_relaxed)); // (3)
    assert(x.load()); // (4)
}

(4) 处的断言就有可能失败. 因为 (2) 与 (3) 之间没有 synchronizes-with 的关系, 所以就不能保证 (1) “happens-before” (4). 因此 (4) 就有可能读到 false.

QQ截图20230114234414

  • 图中x代表atomic类型变量。此时在一个线程上执行时,在这个线程认为不影响最终结果的前提下,实际执行时指令可能完全是乱的。写入a、写入b的操作实际执行时可能是调换了;写入c的操作可能实际在写入x之后执行;读出b的操作实际在写入x之前执行

Relaxed 顺序模型的开销很小. 在 x86 架构下, memory_order_relaxed 的操作不会产生任何其他的指令, 只会影响编译器优化, 确保操作是原子的. Relaxed 模型可以用在一些不需要线程同步的场景, 但是使用时要小心. 例如 std::shared_ptr 增加引用计数时用的就是 memory_order_relaxed, 因为不需要同步; 但是减小应用计数不能用它, 因为需要与析构操作同步

小的总结

操作有效的Memory order枚举值备注
Loadmemory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_seq_cst其它枚举值不合法, MS STL的实现是将其当作memory_order_seq_cst处理
Storememory_order_relaxed, memory_order_release, memory_order_seq_cst同上
read-modify-writememory_order_relaxed, memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel, memory_order_seq_cst 
  • memory_order_relaxed: 最宽松的内存顺序, 只保证操作的原子性修改顺序 (modification order).
  • memory_order_acquire, memory_order_releasememory_order_acq_rel: 实现 acquire 操作release 操作, 如果 acquire 操作读到了 release 操作写入的值, 或其 release sequence 写入的值, 则构成 synchronizes-with 关系, 进而可以推导出 happens-before 的关系.
  • memory_order_consume: 实现 consume 操作, 能实现数据依赖相关的同步关系. 如果 consume 操作读到了 release 操作写入的值, 或其 release sequence 写入的值, 则构成 dependency-ordered before 的关系, 对于有数据依赖的操作可以进而推导出 happens-before 的关系.
  • memory_order_seq_cst: 加强版的 acquire-release 模型, 除了可以实现 synchronizes-with 关系, 还保证全局顺序一致.

参考资料:https://zhuanlan.zhihu.com/p/382372072?utm_id=0

https://blog.csdn.net/wxj1992/category_11580766.html

https://lday.me/2017/12/02/0018_cpp_atomic_summary/

memory_order_acquirememory_order_release 如何搭配使用

往往memory_order_acquirememory_order_release是配合着一起使用的:

  1. 线程1使用memory_order_release写入原子变量x
  2. 线程2使用memory_order_acquire读出原子变量x

所有在线程1上,在写入x之前的写入操作,都将在线程2上,在读出x之后,被看到。使用单向“加载”+单向“释放”协议的场景往往是:

  1. 线程1,写入一些实际数据,接着通过将原子变量x设置为某个值A(通过使用memory_order_release写入原子变量x)来“发布”这些数据。
  2. 线程2,通过读取并判断x已被设置为A(通过使用memory_order_acquire来读取原子变量x),进而读取线程1实际“发布”的那些数据

必须操作的是同一个原子变量。

QQ截图20230115011213

如上图所示,thread_1在release写入x(值:A)之前,写入了待发布的a,b的数据,而thread_2,将在acquire读出x且为A之后,将读到thread_1发布的a,b的数据。同时,我们可以注意到,在thread_2上,在acquire读出x之前,如果对a进行读操作,我们是无法确认读到的a一定会thread_1在之前最后写入的a,这里的顺序是不会被保证的,重排是被允许的。同时,在之后,读取c,读到的是否为thread_1最后写入的c,也是不确定的,因为,在x写入之后,thread_1上又出现了一次写入,而如果在此之前,还有一次写入, 这两次写入之间,是不存在限制,可能会被重排的。

thread_1上有了release_store,对于ab的写入就一定会在x的改变之前,在thread_2上,就不会出现类似读出c,的不确定性。thread_2上有了acquire_load,右侧的读出a,就不会被重排读到左侧,而左侧读出a的不确定性,也不存在。thread_1卡住的是:对于数据ab的写入不能排到x的写入之后,thread_2卡住的,是对于数据ab的读取,不能排到读取x之前,这样,就保证了数据ab,与”信号量”x,之间,在thread_1, thread_2上的同步关系。

Release sequences 释放序列

到目前为止我们看到的, 无论是 sequencial consistent 还是 release-acquire, 要想实现 synchronizes-with 的关系, acquire 操作必须在同一个原子变量上读到 release 操作的写入的值. 如果 acquire 操作没有读到 release 操作写入的值, 那么它俩之间通常没有 synchronizes-with 的关系. 例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
std::atomic<int> x{0}, y{0};

void thread1() {
    x.store(1, std::memory_order_relaxed); // (1)
    y.store(1, std::memory_order_release); // (2)
}

void thread2() {
    y.store(2, std::memory_order_release); // (3)
}

void thread3() {
    while (!y.load(std::memory_order_acquire)); // (4)
    assert(x.load(std::memory_order_relaxed) == 1); // (5)
}

上面的例子中, 只要 y 的值非 0 循环 (4) 就会退出. 当它退出时, 有可能读到 (2) 写入的值, 也有可能读到 (3) 写入的值. 如果是后者, 则只能保证 (3) “synchronizes-with” (4), 不能保证与 (2) 与 (4) 之间有同步关系. 因此 (5) 处的断言就有可能失败.

但并不是只有在 acquire 操作读取到 release 操作写入的值时才能构成 synchronizes-with 关系. 为了说这种情况, 我们需要引入 release sequence 这个概念.

针对一个原子变量 M 的 release 操作 A 完成后, 接下来 M 上可能还会有一连串的其他操作. 如果这一连串操作是由

  • 同一线程上的写操作, 或者
  • 任意线程上的 read-modify-write 操作

这两种构成的, 则称这一连串的操作为以 release 操作 A 为首的 release sequence. 这里的写操作和 read-modify-write 操作可以使用任意内存顺序.

如果一个 acquire 操作在同一个原子变量上读到了一个 release 操作写入的值, 或者读到了以这个 release 操作为首的 release sequence 写入的值, 那么这个 release 操作 “synchronizes-with” 这个 acquire 操作. 我们来看个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
std::vector<int> data;
std::atomic<int> flag{0};

void thread1() {
    data.push_back(42); // (1)
    flag.store(1, std::memory_order_release); // (2)
}

void thread2() {
    int expected = 1;
    while (!flag.compare_exchange_strong(expected, 2, std::memory_order_relaxed)) // (3) 注意此处使用了宽松
        expected = 1;
}

void thread3() {
    while (flag.load(std::memory_order_acquire) < 2); // (4)
    assert(data.at(0) == 42); // (5)
}

上面的例子中, (3) 处的 compare_exchange_strong 是一种 RMW 操作, 它判断原子变量的值是否与期望的值 (第一个参数) 相等, 如果相等则将原子变量设置成目标值 (第二个参数) 并返回 true, 否则将第一个参数 (引用传递) 设置成原子变量当前值并返回 false. 操作 (3) 会一直循环检查, 当 flag 当值为 1 时, 将其替换成 2. 所以 (3) 属于 (2) 的 release sequence. 而循环 (4) 退出时, 它已经读到了 (3) 写入的值, 也就是 release 操作 (2) 为首的 release sequence 写入的值. 所以有 (2) “synchronizes-with” (4). 因此 (1) “happens-before” (5), (5) 处的断言不会失败.

注意 (3) 处的 compare_exchange_strong 的内存顺序是 memory_order_relaxed, 所以 (2) 与 (3) 并不构成 synchronizes-with 的关系. 也就是说, 当循环 (3) 退出时, 并不能保证 thread2 能读到 data.at(0) 为 42. 但是 (3) 属于 (2) 的 release sequence, 当 (4) 以 memory_order_acquire 的内存顺序读到 (2) 的 release sequence 写入的值时, 可以与 (2) 构成 synchronizes-with 的关系

注意, 这种只是一种概念. 依赖于代码的正确性. 同时, 我们说 “当循环 (3) 退出时, 并不能保证 thread2 能读到 data.at(0) 为 42”的原因是 这个插入操作由于缓存一致性的原因, 并不保证被刷新到thread2的cache内. 尽管从逻辑上讲, 当flag从1变为2的时候, 写入操作已经发生了, 但是这个时候这个写入并不一定保证对thread2可见. 当然了, 假设这个原子变量和写入数据在一个cache line中, 这个操作可能对thread2是可见的

如何理解内存序和锁之间的联系?

多线程编程,临界区是一个很重要的概念。我们对此再做进一步的认识。

对于临界区区内的语句,显而易见,我们不能将其移出临界区,如下图所示,编译器和CPU都不会做这种移出临界区的优化:

QQ截图20230115011701

但我们可以将临界区区外的代码移进来,如下图所示

QQ截图20230115011724

QQ截图20230116025012

从上面两张图我们可以看出,lockunlock可以看作两个单方向的屏障,lock对应的屏障,只允许代码往下方向移动,而`unlock则只允许上方向移动。

所以我们是不是发现:memory_order_acquire类似lockmemory_order_release类似unlock,这两个都是单方向的屏障(One-way Barriers: acquire barrier, release barrier)。

所以借助内存屏障+原子变量,在比互斥锁更底层的层面,我们也能实现线程间同步。比如下图的例子:

p原子变量,当线程2看到p为非空时,后续的r1 = A语句可以看到线程1对A的写操作结果。这是因为线程1对pstore操作Synchronizes-with线程2对pload操作,这样加之Happens-before关系,我们实现了线程间同步。值得一提的是,线程1中,A=B+1Happens-before B = 1,但B = 1可能先执行,但线程1对A的写操作的结果,依旧能正确地被线程2中int r1 = A语句读取。

换句话说,我们利用了memory_order_release,使得针对AB的写入一定会发生在写入原子变量p之前。我们利用了memory_order_acquire使得针对Ax的读取一定发生在读取原子变量p之前。

有人也许会问,用非原子变量难道不能实现Synchronizes-with关系?首先,非原子变量,显然不能被多个线程同时读写,再者,其无法提供内存屏障,不要忘了前文重排的例子,试想,若线程1中AB的写入操作被重排到了p.store的后面,且即使替换p.store的非原子变量操作在多线程下可以正确执行,但此时int r1 = A能读取到正确的值吗?

QQ截图20220909144257

这里所谓的指定内存序,指的是对执行语句所在的线程内部的限制,也就是只影响一个cpu核心,但是这些对单线程内部的限制组合起来就能实现多线程之间数据同步的效果

同步的传递性和acquire搭配release的小例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
atomic<int> data[5];
atomic<bool> sync1(false), sync2(false);

void thread_1_func(){
  data[0].store(1, memory_order_relaxed);
  data[1].store(2, memory_order_relaxed);
  data[2].store(3, memory_order_relaxed);
  data[3].store(4, memory_order_relaxed);
  data[4].store(5, memory_order_relaxed);
  sync1.store(true, memory_order_release); //注释1
}

void thread_2_func(){
  while(!sync1.load(memory_order_acquire)); //注释2
  sync2.store(true, memory_order_release); //注释3
}

void thread_3_func(){
  while(!sync2.load(memory_order_acquire)); //注释4
  assert(data[0].load(memory_order_relaxed) == 1);
  assert(data[1].load(memory_order_relaxed) == 2);
  assert(data[2].load(memory_order_relaxed) == 3);
  assert(data[3].load(memory_order_relaxed) == 4);
  assert(data[4].load(memory_order_relaxed) == 5);
  cout << "all success" << endl;
}

int main(){
  thread th1(thread_1_func);
  thread th2(thread_2_func);
  thread th3(thread_3_func);

  th1.join();
  th2.join();
  th3.join();
  return 0;
}
  • func1当中,我们的数据存储使用了relaxed,所以可能是乱序,但是无关紧要。我们的核心目的是保证thread3能看到thread1中存储的全部数据。所以:
  • func1中,我们针对sync1的写入使用了release。也就是说,在把sync1修改为true的这个操作之前,可以保证前面的操作全部完成,不会重排到这个操作之后,同时也保证缓存一致性,也就是之前的操作全部对其他线程可见。
  • func2中,我们针对sync1的读取使用了acquire操作。也就是说,在读取到sync1true之前,任何操作不能被重排到该操作之前。也就保证了针对sync2的修改一定发生在发现sync1true之后。
  • 随后在func2中,我们针对sync3的写入使用了release。原因同上。
  • func3中,针对sync3的读取使用了acquire,原因同上。
  • 最后在assert中,乱序可能发生,但是无所谓。

QQ截图20230115184315

  • acquirerelease的语义可以形象理解为:如果我不释放(release),你的请求(acquire)必须等待,直到我释放为止。

atomic_thread_fence分类和效果

在C++ 11及之后的标准里,除了利用原子操作指定内存序,还定义了单独使用memory fence(std::atomic_thread_fence)的方式,fence可以和原子操作组合进行同步,也可以fence之间进行同步,fence不光可以不依赖原子操作进行同步,而且相比较于同样memory order的原子操作,具有更强的内存同步效果

atomic变量类似,atomic_thread_fence也可以指定六种内存序,指定不同内存序的fence可以分为以下几类: (1) std::atomic_thread_fence(memory_order_relaxed),没有任何效果。 (2) std::atomic_thread_fence(memory_order_acquire)std::atomic_thread_fence(memory_order_consume) 属于acquire fence。 (3)std::atomic_thread_fence(memory_order_release)属于release fence。 (4)std::atomic_thread_fence(memory_order_acq_rel)既是acquire fence 也是release fence,为了方便这里称为full fence。 (5)std::atomic_thread_fence(memory_order_seq_cst)额外保证有单独全序的full fence。

也就是说,如果不考虑单独全序,那么有release fence、acquire fence 和full fence三种。下面就根据以前介绍过的四种重排来介绍下这三种fence的效果。

不同类型的Fence对于乱序的保护是不一样的。我们可以将读和写的交错分成下面四种情况:

  • Load-Load:读接着读
  • Load-Store:先读后写
  • Store-Load:先写后读
  • Store-Store:写接着写

release fence

  • Release fence可以防止fence前的内存操作重排到fence后的任意store(写入)之后,即阻止load-store重排和store-store重排。(阻止了所有在它之前的读写操作和在它之后的写操作乱序

QQ截图20220909153644

acquire fence

  • acquire fence可以防止fence后的内存操作重排到fence前的任意load(读取)之前,即阻止load-load重排和load-store重排。(阻止了所有在它之前的读操作与在它之后的读写操作乱序。

QQ截图20220909153840

full fence

  • 因为full fence是release fence和acquire fence的组合,所以也就是防止load-load、load-store、store-store重排

QQ截图20220909154223

C++标准中,三种fence不禁止store-load(先写后读)的重排。

即便是std::atomic_thread_fence(memory_order_seq_cst)也一样,只是需要额外保证单独全序,但是在实际的实现上为了实现这个全序编译器大都是采用了硬件层面的能够阻止storeload重排的full barrier指令

参考资料:

https://paul.pub/cpp-memory-model/

https://blog.csdn.net/wxj1992/article/details/103917093

https://luyuhuang.tech/2022/06/25/cpp-memory-order.html

https://zhuanlan.zhihu.com/p/682286231

来自百度的介绍内存访问,分配,MESI,内存序优化的简短文章

memory barrier 和 memory fence的细微区别

chatGPT的回答:

在C++11标准中,memory fence和memory barrier是等价的概念,它们都用于确保多线程环境下的内存可见性和顺序性。

在C++11之前,memory fence通常是指硬件级别的操作,而memory barrier则是指编译器级别的操作,但是这种区分在C++11之后已经不再重要。

在C++11中,memory fence和memory barrier都有两种类型:acquire和release。acquire fence/barrier用于确保之前的读操作在当前之后的操作之前执行,release fence/barrier用于确保之后的写操作在当前之前的操作之后执行。

区别在于,memory fence是一种完全的内存屏障,它会禁止编译器和CPU对内存操作的任何优化,强制所有线程按照特定的顺序执行内存操作。而memory barrier只是一种编译器屏障,它只能保证编译器不会对内存操作进行优化,但不能保证CPU不进行优化,因此在某些平台上可能不够稳定。所以memory barrier又称为 compiler barrier

此外,memory fence和memory barrier的使用需要根据具体的情况来确定。通常,只有在需要非常精细的内存控制时才需要使用memory fence和memory barrier,因为它们会影响程序的性能。在一般情况下,可以通过使用std::atomic类型来实现线程安全的操作,它会自动处理内存屏障和同步操作。

QQ截图20230421014842

NULL和nullptr区别

其实NULL根据命名全大写可以看出来,它是一个常量,既然是常量,就需要进行宏定义。

  • C语言的标准头文件是这样定义的 #define NULL ((void*)0)
  • 而到了C++中,则变成了#define NULL 0

从定义中可以看出,C++中,NULL其实就是0,但是也可以用作空指针,只是用作空指针可能是为了兼容C,迫于无奈。

但是当NULL既可以表示0又可以表示空指针的时候,发生函数重载的时候就会有二义性

1
2
3
4
5
6
7
8
9
10
11
12
13
void test(void *p)
{
    cout<<"p is pointer "<<p<<endl;
 }
void test(int num)
{
    cout<<"num is int "<<num<<endl; 
}
int main(void)
{
    test(NULL); //NULL是int还是指针?
    return 0; 
}

很明显,NULL存在二义性,它既是整数,也是一个指针,函数test()无法根据参数的数据类型判断应该调用哪一个实现。 这时使用nullptr的优越性就体现出来了,因为它可以很好地把空指针这一层意思给剥离出来。nullptr就是C++11为了解决这个痛点而推出的东西。

lockguard和uniquelock

注意一下锁的含义。为什么锁叫互斥量?不要认为锁和资源是挨着的。或者是资源一定和锁连着。

锁,互斥量是独立的。什么意思?假设我们有规定:想要打开抽屉,必须从桌子上拿走令牌。如果没有令牌则不能打开抽屉。所以说资源(抽屉)和锁(互斥量)不一定在一起。资源在抽屉里,锁在桌子上。两者是分离的。我只要确保每个人都可以访问到令牌(锁)和抽屉(资源)即可。

  • unique_lock功能丰富灵活得多。如果需要实现更复杂的锁策略可以用unique_lock
  • 如果只需要基本的锁功能,优先使用更严格高效的lock_guard
  • 注意区分mutexlock_guard/unique_lock。后者是前者的RAII包装器。所以如果要锁的话必须这样
1
2
mutex lock;
unique_lock<mutex> lkc(lock);

两种锁的简单概述与策略对比见下表:

类模板描述策略
std::lock_guard严格基于作用域(scope-based)的锁管理类模板,构造时是否加锁是可选的(不加锁时假定当前线程已经获得锁的所有权—使用std::adopt_lock策略),析构时自动释放锁,所有权不可转移,对象生存期内不允许手动加锁和释放锁std::adopt_lock
std::unique_lock更加灵活的锁管理类模板,构造时是否加锁是可选的,在对象析构时如果持有锁会自动释放锁,所有权可以转移。对象生命期内允许手动加锁和释放锁std::adopt_lock std::defer_lock std::try_to_lock

QQ截图20220926064910

QQ截图20220926064919

  • lock_guard不能手动解锁。这也是为什么使用条件变量的时候互斥锁一定要使用unique_lock,因为wait函数内部会进行解锁。详细查看项目相关的条件变量。
  • lock_guard不能创建时不锁定,也就是没有defer功能。

  • 使用std::lock对多个unique_lock一起加锁可以避免死锁
    • 同时,只要传入的多个锁中的一个发生了异常,则所有已锁定的对象都会被解锁。也就是要么全都锁,要么全都不锁。
    • 原理其实也是一个包装器。遵循了多线程按照同一个顺序对多个锁进行加锁一定不会死锁 这一个唯一原则。

shared_lock

shared_lock本身和unique_lock的接口差不多.

一般我们是用shared_lock管理共享互斥量. 如shared_mutex. 注意, 非共享互斥量不可以用shared_lock管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#include <iostream>
#include <mutex>    //unique_lock
#include <shared_mutex> //shared_mutex shared_lock
#include <thread>

std::mutex mtx;

class ThreadSaferCounter
{
private:
    mutable std::shared_mutex mutex_; // 这个mutable的作用是在下面的const成员函数中可以修改它. 更多查看杂记2
    unsigned int value_ = 0;
public:
    ThreadSaferCounter(/* args */) {};
    ~ThreadSaferCounter() {};
    
    unsigned int get() const {
        //读者, 获取共享锁, 使用shared_lock
        std::shared_lock<std::shared_mutex> lck(mutex_);//等同于执行mutex_.lock_shared();
        return value_;  //lck 析构, 执行mutex_.unlock_shared();
    }

    unsigned int increment() {
        //写者, 获取独占锁, 使用unique_lock
        std::unique_lock<std::shared_mutex> lck(mutex_);//等同于执行mutex_.lock();
        value_++;   //lck 析构, 执行mutex_.unlock();
        return value_;
    }

    void reset() {
        //写者, 获取独占锁, 使用unique_lock
        std::unique_lock<std::shared_mutex> lck(mutex_);//等同于执行mutex_.lock();
        value_ = 0;   //lck 析构, 执行mutex_.unlock();
    }
};
ThreadSaferCounter counter;
void reader(int id){
    while (true)
    {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::unique_lock<std::mutex> ulck(mtx);//cout也需要锁去保护, 否则输出乱序
        std::cout << "reader #" << id << " get value " << counter.get() << "\n";
    }    
}

void writer(int id){
    while (true)
    {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::unique_lock<std::mutex> ulck(mtx);//cout也需要锁去保护, 否则输出乱序
        std::cout << "writer #" << id << " write value " << counter.increment() << "\n";
    }
}

int main()
{
    std::thread rth[10];
    std::thread wth[10];
    for(int i=0; i<10; i++){
        rth[i] = std::thread(reader, i+1);
    }
    for(int i=0; i<10; i++){
        wth[i] = std::thread(writer, i+1);
    }

    for(int i=0; i<10; i++){
        rth[i].join();
    }
    for(int i=0; i<10; i++){
        wth[i].join();
    }
    return 0;
}

通过实例我们可以得知. 真正的锁本身其实是互斥量. 互斥量本身需要是shared_mutex.

所以:

  • 读锁使用shared_lock锁定shared_mutex
  • 写锁使用unique_lock锁定.shared_mutex

参考来自

互斥锁的底层实现

https://zhiqiang.org/coding/std-mutex-implement.html

  • 内存中准备一个表示锁定状态的整数
  • 使用CAS操作来尝试修改值(上锁)
  • 提供一个函数让互斥锁在被锁定的情况下等待。Linux里面这个操作是futex系统调用。这会将线程放入队列中,并监视内存中的整数。
  • 可能包括防止指令重排的内存屏障

Futex

为什么要有futex

我们已经有了互斥锁和自旋锁。我们也知道了它们的优缺点。自旋锁的主要缺点是在等待时浪费CPU资源。互斥锁的主要缺点是就算无需等待(争抢),也需要进行上下文切换进行系统调用。那么我们为何不将二者的缺点去掉,保留优点呢?所以Futex就来了。

Futex是一种用户态和内核态混合的同步机制。同步的进程间通过mmap共享一段内存,futex变量就位于这段共享的内存中且操作是原子的,当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不用再执行系统调用,仅当通过访问futex变量告诉进程有竞争发生时,才执行系统调用去完成相应的处理。总结就是:使用一条原子指令,如果上锁成功立即返回。如果上锁失败,执行系统调用进行睡眠。

  • 首先,同步的进程间通过mmap共享一段内存,futex变量就位于这段共享的内存中。且操作是原子的
  • 当进程尝试进入互斥区或者退出互斥区的时候,先去查看共享内存中的futex变量,如果没有竞争发生,则只修改futex,而不 用再执行系统调用了。
  • 当通过访问futex变量告诉进程有竞争发生,则还是得执行系统调用去完成相应的处理(wait 或者 wake up)。
  • 当进程尝试持有锁或者要进入互斥区的时候,对futex执行”down”操作,即原子性的给futex同步变量减1
    • 如果同步变量变为0,则没有竞争发生, 进程照常执行。无需进入内核态进行系统调用。
    • 如果同步变量是个负数,则意味着有竞争发生,需要调用futex系统调用的futex_wait操作休眠当前进程。
  • 当进程释放锁或者要离开互斥区的时候,对futex进行”up”操作,即原子性的给futex同步变量加1。
    • 如果同步变量由0变成1,则没有竞争发生,进程照常执行。无需进入内核态进行系统调用。
    • 如果加之前同步变量是负数,则意味着有竞争发生,需要调用futex系统调用的futex_wake操作唤醒一个或者多个等待进程。
1
2
3
4
5
//uaddr指向一个地址,val代表这个地址期待的值,当*uaddr==val时,才会进行wait
int futex_wait(int *uaddr, int val);

//唤醒n个在uaddr指向的锁变量上挂起等待的进程
int futex_wake(int *uaddr, int n);
  • futex_wait是用来协助加锁操作的。线程调用pthread_mutex_lock,如果发现锁的值不是0,就会调用futex_wait,告知内核,线程须要等待在uaddr对应的锁上,请将线程挂起。内核会建立与uaddr地址对应的等待队列。
    • 为什么需要内核维护等待队列?因为一旦互斥量的持有者线程释放了互斥量,就需要及时通知那些等待在该互斥量上的线程。如果没有等待队列,内核将无法通知到那些正陷入阻塞的线程。
    • 如果整个系统有很多这种互斥量,是不是需要为每个uaddr地址建立一个等待队列呢?事实上不需要。理论上讲,futex只需要在内核之中维护一个队列就够了,当线程释放互斥量时,可能会调用futex_wake,此时会将uaddr传进来,内核会去遍历该队列,查找等待在该uaddr地址上的线程,并将相应的线程唤醒。
  • futex_wake操作是用来实现解锁操作的。glibc就是使用该操作来实现互斥量的解锁函数pthread_mutex_unlock的。当线程执行完临界区代码,解锁时,内核需要通知那些正在等待该锁的线程。这时候就需要发挥futex_wake操作的作用了。futex_wake的第二个参数n,表明需要唤醒几个睡眠的线程。对于互斥量而言,该值总是1,表示唤醒1个线程。当然,也可以唤醒所有正在等待该锁的线程,但是这样做并无好处,因为被唤醒的多个线程会再次竞争,却只能有一个线程抢到锁,这时其他线程不得不再次睡去,徒增了很多开销。

QQ截图20230423212039

然而,实际上,我们会使用下面这个大的futex系统调用,通过置入选项的方式来决定是调用futex_wait还是futex_wake

1
2
3
long syscall(SYS_futex, uint32_t *uaddr, int futex_op, uint32_t val,
                    const struct timespec *timeout,   /* or: uint32_t val2 */
                    uint32_t *uaddr2, uint32_t val3);

futex无论在什么平台都是32位。同时,第二个参数futex_op用来指明到底要等待还是唤醒。

Futex的等待队列

我们说过,futex变量创建于用户空间,在进程或线程间共享,当进程或线程想要进入临界区时,通常会判断futex变量是否满足条件,若满足则成功进入临界区,否则则阻塞在该futex变量上;当进程或线程将要离开临界区时,则会唤醒阻塞在futex变量上的其他进程或线程。在内核中通过struct futex_q结构将一个futex变量与一个挂起的进程(线程)关联起来,其定义以及关键成员的作用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct futex_q {
    struct plist_node list;        //链表节点
    struct task_struct *task;      //挂起在该futex变量关联的进程(线程)
    spinlock_t *lock_ptr;          //自旋锁,控制链表访问
    union futex_key key;           //futex变量地址标识

    //下面三个与优先级继承相关,在此不多介绍
    struct futex_pi_state *pi_state;
    struct rt_mutex_waiter *rt_waiter;
    union futex_key *requeue_pi_key;
     
    u32 bitset;                    //类似掩码匹配
};

内核中通过一个全局哈希表来维护所有挂起阻塞在futex变量上的进程(线程),不同的futex变量会根据其地址标识计算出一个hash key并定位到一个bucket上,因此挂起阻塞在同一个futex变量的所有进程(线程)会对应到同一个bucket上,数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//bucket
struct futex_hash_bucket {
    //当前自旋等待哈希桶的waiter数目
	atomic_t waiters;

    //自旋锁,用于控制chain的访问,
    //struct futex_q中lock_ptr,就是引用其所在的bucket的自旋锁
	spinlock_t lock;

	//优先级链,与传统等待队列不同,futex使用优先级链表来实现等待队列,
    //是为了实现优先级继承,从而解决优先级翻转问题
	struct plist_head chain;
} ____cacheline_aligned_in_smp;

//全局哈希表
static struct {
	struct futex_hash_bucket *queues;
	unsigned long            hashsize;
} __futex_data __read_mostly __aligned(2*sizeof(long));
#define futex_queues   (__futex_data.queues)
#define futex_hashsize (__futex_data.hashsize)

参考来自这里这里,Linux环境编程 7.7.4 和 南京大学操作系统课程2021P9

futex 的基本思想是竞争态总是很少发生的,只有在竞争态才需要进入内核,否则在用户态即可完成。futex的两个目标是:

  • 尽量避免系统调用;(因为需要切换到内核态。比如线程的挂起和唤醒都需要切换至内核态)
  • 避免不必要的上下文切换(导致的TLB失效等)。

不同操作系统的futex区别

https://outerproduct.net/futex-dictionary.html

当做扩展阅读看看就行

自旋锁的底层实现

区别就是不挂起,一直使用CAS尝试上锁。

自旋锁和互斥锁的区别

  • 自旋锁是一种非阻塞锁,也就是说,如果某线程需要获取自旋锁,但该锁已经被其他线程占用时,该线程不会被挂起,而是在不断的消耗CPU的时间,不停的试图获取自旋锁不会引起切换

  • 互斥量是阻塞锁,当某线程无法获取互斥量时,该线程会被直接挂起,该线程不再消耗CPU时间,当其他线程释放互斥量后,操作系统会激活那个被挂起的线程,让其投入运行会引起切换

  • 两种锁适用于不同场景:

    • 如果是多核处理器,如果预计线程等待锁的时间很短,短到比线程两次上下文切换时间要少的情况下,使用自旋锁是划算的。
    • 如果是多核处理器,如果预计线程等待锁的时间较长,至少比两次线程上下文切换的时间要长,建议使用互斥量。
    • 如果是单核处理器,一般建议不要使用自旋锁。因为,在同一时间只有一个线程是处在运行状态,那如果运行线程发现无法获取锁,只能等待解锁,但因为自身不挂起,所以那个获取到锁的线程没有办法进入运行状态,只能等到运行线程把操作系统分给它的时间片用完,才能有机会被调度。这种情况下使用自旋锁的代价很高。
    • 如果加锁的代码经常被调用,但竞争情况很少发生时,应该优先考虑使用自旋锁,自旋锁的开销比较小,互斥量的开销较大

自旋锁的真正使用场景应该是操作系统内核的并发数据结构。

可重入锁和不可重入锁

简单来说我们说过同一个线程对一个锁上锁两次会被死锁。因为第二次进行上锁的时候会尝试拿锁,但是一直拿不到。所以线程会被挂起。除非其他线程帮我们解锁。(查看笔试题整理)

  • 可重入锁(reentrant lock):

    • 可重入锁简单如字面而言,就是可以重新进入的锁,允许同一进程多次获取同一把锁,是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提是同一个对象或者class),这样的锁就叫做可重入锁,也叫做递归锁。
    • recursive_mutex
  • 不可重入锁(non-reentrant lock):
    • 不可重入锁与可重入锁相反,如果当前线程已经获取了该锁,那么再次尝试获取该锁时,就会出现死锁的状况,被阻塞。在这个线程解锁之前,其他线程无法用这个锁再来加锁。
  • 可重入锁不与可重入锁的区别:
    • 不可重入锁只判断这个锁有没有被锁上,只要被锁上申请锁的线程都会被要求等待。实现起来较为简单。而可重入锁不仅要判断锁有没有被锁上,还会判断锁是谁锁上的,当就是自己锁上的时候,那么他依旧可以再次访问临界资源,并把加锁次数加一。

CAS 的底层实现

汇编指令LOCK CMPXCHG 让CPU锁住cache line。获取该cache line的独占权来针对原子变量进行原子操作。

https://www.cnblogs.com/kendoziyu/p/16571887.html

https://stackoverflow.com/questions/27837731/is-x86-cmpxchg-atomic-if-so-why-does-it-need-lock

CAS 的缺点

  • 自旋时间太长

    • 如果CAS一直不成功呢?这种情况绝对有可能发生,如果自旋CAS长时间地不成功,则会给CPU带来非常大的开销。
  • 只能保证一个共享变量原子操作

    • 看了CAS的实现就知道这只能针对一个共享变量,如果是多个共享变量就只能使用锁了,当然如果你有办法把多个变量整成一个变量,利用CAS也不错。例如读写锁中state的高低位。(https://www.cnblogs.com/wait-pigblog/p/9350569.html)
  • ABA问题

    • CAS需要检查操作值有没有发生改变,如果没有发生改变则更新。但是存在这样一种情况:如果一个值原来是A,变成了B,然后又变成了A,那么在CAS检查的时候会发现没有改变,但是实质上它已经发生了改变,只是又回到了原来的值而已,这就是所谓的ABA问题。对于ABA问题其解决方案是加上版本号,即在每个变量都加上一个版本号,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A。

实现一个简单的自旋锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <atomic>

class SpinLock {

public:
    SpinLock() : flag_(false)
    {}

    void lock()
    {
        bool expect = false; //注意,这里就是为什么这个代码不会发生竞争。这里的expect是局部变量
        while (!flag_.compare_exchange_weak(expect, true))
        {
            //这里一定要将expect复原,因为如果当前flag是true,expect是false,不相等的话exchange会把expect换成当前flag值也就是换成true。如果不换回去就拉稀了。
            expect = false;
        }
    }

    void unlock()
    {
        flag_.store(false);
    }

private:
    std::atomic<bool> flag_;
};

int main(){
    SpinLock myLock;
    myLock.lock();

    //do something

    myLock.unlock();
    return 0;
}

我们看一下原理。

  1. 首先我们初始设定类内的原子变量为false。
  2. 当我们上锁的时候:
    1. 第一个线程进来,进行compare_exchange_weak判断。此时发现当前值和expect值相同。则设定当前值为true
    2. 又因为返回值不为false,不进入while循环。跳出。
    3. 剩下的线程进来的时候,发现当前值是true,和expect值不同。则修改期望值(expect)为当前值也就是true。同时返回值是false,进入while循环忙等待。
    4. 此时进入循环后必须继续将expect设置为false。因为我们之前把expect修改为true
  3. 直到拿到锁的线程调用unlock,设置当前值为false。这时候其他线程之一发现,当前值和expect值相同,继续重复第二步
  • 注意,我们的expectlock函数内的局部变量。意思就是每一个进入到该函数的线程都会有一个自己独有的expect。所以不会发生一个线程修改expecttrue之后,在修改回false之前另一个线程进来发现已经是true了直接就拿到了锁的这个情况。

https://www.cnblogs.com/FateTHarlaown/p/9170474.html

更为精简和高性能的自旋锁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class SpinLock{
    public:
        SpinLock() = default;
        
        void lock(){
            while(_flag.test_and_set(std::memory_order_acquire));
        }
        void unlock(){
            _flag.clear(std::memory_order_release);
        }

    private:
        std::atomic_flag _flag = ATOMIC_FLAG_INIT;
};
  • 首先我们把atomic变量换成了简单的atomic_flag
  • 注意,在lock函数中,test_and_set的作用是给flag原子赋值为true并返回之前的值。也就是说当有线程抢锁的时候,发现当前的flag已经是true了,就再次设定为true并且返回当前的true值。while循环就是要判断如果返回的是true就一直循环等待。
  • 内存序更为合适:我们反复强调了acquire理解为lock。也就是针对这一行原子操作之后的操作不允许前移。release操作理解为unlock,也就是针对这一行原子操作之前的操作不允许后移。如果使用全序模型则会降低性能。但是这里我们并不阻止合法的指令重排。

我们可以对任何带有lock()unlock()函数的互斥类使用lock_guard。看一眼源码就知道了。

前向声明

前向声明可以解决两个类互相包含的问题。因为前向声明属于不完整类型,所以有如下限制:

  • 可以
    • 将成员声明为指向不完整类型的指针或引用
    • 声明接受/返回不完整类型的函数或方法
    • 定义接受/返回指向不完整类型的指针/引用的函数或方法(但不使用其成员)
  • 不可以
    • 将其用作基类
    • 用它来声明一个成员(使用对象)
    • 使用该类型定义函数或方法
    • 使用其方法或字段,实际上试图解引用类型不完整的变量
      • 这里就是使用这个前向声明类型的指针然后对其解引用使用其类型包含的数据或方法。
  • 实际上只需要分文件或者类外编写函数的定义就可以了。也就是实际使用的时候等到类已经被正式定义完毕就可以了。

模板相关

类模板定义规则

  • 注意c++类模板定义方法 类模板没有自动类型推导。所以只要使用了就必须显式指定参数类型。
  • 但是类模板可以有默认参数和偏特化
  • 类模板中的成员函数只有在调用的时候才会被创建。因为在编译阶段,编译器无法确认模板的参数类型,所以无法创建模板类成员函数
1
2
3
4
template<typename 模板参数表>
class 类名{
    // 类定义......
};
  • 注意全特化和偏特化的语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<typename T1, typename T2>
class test{
    T1 data1;
    T2 data2;
};
//全特化
template<>
class test<int, float>{
    int data1;
};
//偏特化
template<typename T2>
class test<int, T2>{
    int data1;
};

模板调用顺序 规则

  • 类模板: 对主版本模板类、全特化类、偏特化类的调用优先级从高到低进行排序是:全特化类>偏特化类>主版本模板类。这样的优先级顺序对性能也是最好的。
  • 函数模板: 如果函数模板和普通函数都可以实现,优先调用普通函数 可以使用空模板参数列表来强制调用函数模板 函数模板也可以重载 如果函数版可以产生更好的匹配,则优先调用函数模板.
    • 函数模板只能全特化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void test(int a, int b){
    cout << "普通函数" << endl;
}

template <typename T>
void test(T a, T b){
    cout << "模板函数" << endl;
}

template <typename T>
void test(T a, T b, T c){
    cout << "重载的模板函数" << endl;
}

int main(){
    test(1,2); //输出普通函数
    test<>(1,2); //使用空模板参数列表 输出模板函数
    test(1,2,3); //输出重载的模板函数
}

模板模板参数

模板的模板参数的经典应用是在某些自定义指定储存容器类型的时候不指定元素类型。

  • 最基本的举例
1
2
3
4
5
template<template<typename> class container, typename T1, typename T2> // 最基本的举例
class test{
    container<T1> a1;
    container<T2> a2;
};
  • 茴字的三种写法, 都可以
1
2
3
template <typename T, template <typename> class Container>
template <typename T, template <class> class Container>
template <typename T, template <typename> typename Container>
  • 更好的理解的写法:
1
template <typename T, template <typename T1> typename Container>
  • 更多例子参见template_template_parameter_*文件

模板参数按值传递还是引用传递

https://vinkle.top/2021/06/12/cpp-template-7/#7-%E6%8C%89%E5%80%BC%E4%BC%A0%E9%80%92%E8%BF%98%E6%98%AF%E6%8C%89%E5%BC%95%E7%94%A8%E4%BC%A0%E9%80%92

非类型模板参数

这块之前居然忽略了。必须简单说一下

1
2
3
4
5
6
7
8
9
template<typename T>
void func(T obj){
    //...
}

int main(){
    func<int>(5);
    return 0;
}

我们都知道模板参数一般都是类型。比如这里,T就是int。是类型。

但是如果我们有时候需要一些特殊情况, 例如想要传点奇怪东西的时候。

1
2
3
4
5
6
7
8
9
template<typename T, int MAXSIZE>
void func1(T obj){
    vector<T>a;
    a.reserve(MAXSIZE);
    cout << a.capacity() << endl;
    a.push_back(obj);
    cout << a[0] << endl; 			//干啥了不解释了 忽略即可
}

这里的int MAXSIZE就是非类型模板参数。因为他不是类型,而是变量。

使用非类型模板参数是有限制的。通常它们只能是:

  1. 整型常量或字面值(包含枚举,或可隐式转换的比如bool)(string double都不可以。前者是类对象,后者是浮点数)
  2. 指向对象/函数/成员变量的指针
  3. 对象/函数的左值引用
  4. std::nullptr_t

当传递对象的指针或者引用作为模板参数时,对象不能是字符串常量,临时变量或者数据成员以及其他子对象。由于C++17之前,C++每次版本更新都会放宽以上限制,因此还有一些针对不同版本的限制:

  • C++11中,对象必须要有外部链接
  • C++14中,对象必须是外部链接或者内部链接

所以:传入的s必须是常量。

1
2
3
const int s = 8;	//必须是const
func1<int, s>(5);
func2<int, 4>(5); //或者直接传入字面值。

搭配类模板偏特化的小例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
template<typename T, bool option>
class myclass; //主模板不实现

template<typename T>
class myclass<T, true>{ //偏特化1,注意语法。T在这里依旧要写上。
    public:
    void func(){
        cout <<"true one" << endl;
    }
};


template<typename T>
class myclass<T, false>{ //偏特化2,注意语法。T在这里依旧要写上。
    public:
    void func(){
        cout <<"false one" << endl;
    }
};

int main(){
    const bool myoption = true;
    myclass<int, false> obj; //直接使用字面值
    obj.func();
    myclass<int, myoption> obj1; //或必须用const常量变量。
    obj1.func();
    return 0;
}

enable_if

简单看一下原型

1
2
3
4
5
6
7
template <bool, typename T=void>
struct enable_if {
};
template <typename T>
struct enable_if<true, T> { ///< 第一个模板参数为 true
  using type = T;           ///< type 才有定义
};

意思就是,前面的表达式为真,后面的类型定义才有意义。

一般三种用法:

  • 控制函数返回类型
  • 校验函数模板参数类型
  • 类型偏特化

这里就随便写个控制函数返回类型的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<int stat> //这里是非模板类型参数。上面提到了。注意一下
typename enable_if<stat == 1, bool>::type checkstate(){
    cout <<"type is bool" << endl;
    return true;
}
template<int stat>
typename enable_if<stat == 0, int>::type checkstate(){
    cout <<"type is int" << endl;
    return 5;
}

template<bool stat>			//bool也可以。隐式转换为整型了,但是必须要常量。
typename enable_if<stat == true, int>::type checkstate1(){
    cout <<"type is int" << endl;
    return 5;
}

int main(){
    const int myobj = 1; 	//必须是const
    checkstate<myobj>();	//输出"type is bool" 
    checkstate<0>();		//输出"type is int" 
    return 0;
}

注意事项:返回类型前必须加typename来告知enable_if::type是个类型。还有就是非模板类型参数的限制。

函数的变长参数模板

  • 基本定义
1
2
3
4
template <typename T, typename... Args> 
// 如果函数参数列表中一个参数的类型是一个模板参数包,
// 则此参数也是一个函数参数包
void func(const T& t, const Args&... rest);
  • C++17之前的写法:因为没有折叠表达式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void print (){
//必须要有无参重载,否则会无限递归。也就是最后一次无参无法被调用。
//最后一次被解包后,参数包会为空。所以会调用无参函数。
}
template<typename T, typename... Args>
void print (T firstArg, Args... args)
{
    cout << firstArg << endl; //print first argument
    print(args...); // call print() for remaining arguments
}
int main() 
{
    print(1, 1000, "b23", 1.123, "HahaahaH", 42);
    return 0;
}
  • C++17之后的写法:有了折叠表达式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T> //要有单参打印的重载。不然会无限调用。
void print(T t){
    cout << t << endl;
}


template<typename... Args>
void print(Args... args){
    (print(args),...); //这里外部一定要加括号。注意语法
}


int main() 
{
    print(1, 1000, "b23", 1.123, "HahaahaH", 42);
    return 0;
}
  • 多种变长参数模板可以同时存在 尽管直观看起来会有二义性

搭配列表初始化

  • 函数变长参数模板搭配列表初始化。
    • 这个函数会返回一个T类型的vector,元素是args
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
template<typename T, typename... Args>
vector<T> func(const Args&... args){
    return {args...};
}
int main(){
    auto vec = func<int>(1,2,3,4,5,6,7);
    for(auto& i:vec){
        cout << i << endl;
    }
}
/*
输出:
1
2
3
4
5
6
7
*/

类的变长参数模板

  • 基本定义:
1
template<typename... Ts> class Variadic;
  • 其模板实例化:

    • 0个参数的模板类 Variadic<> zero;

    • 多个参数的模板类 Variadic<int, double, std::string, std::list<int>> sample;

模板类对象做为函数参数传入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
template<typename T1, typename T2>
class test{
public:
    T1 _a;
    T2 _b;
    test(T1 a, T2 b) : _a(a), _b(b) {}
};

void testprint1(test<int,int>& obj){ //方法1 显式指定传入类型
    cout << obj._a << obj._b << endl;
}

template<typename T1, typename T2> 
void testprint2(test<T1, T2>& obj){ //方法2 使用参数模板
    cout << obj._a << obj._b << endl;
}


template<typename T>
void testprint3(T& obj){ //方法3 利用函数模板的自动推导特性
    cout << obj._a << obj._b << endl;
}

int main(){
    test<int,int> b(5,5);
    testprint1(b);
    testprint2(b);
    testprint3(b);
}

类模板 模板类 的继承 子类调用父类构造函数

  • 当有多继承的时候,子类继承的父类必须全部拥有默认无参构造函数。因为创建子类对象时,一定会先调用父类构造函数。如果子类的构造函数没有指明调用父类的有参构造的话,就会调用默认构造。但是一旦类有了一个构造函数,编译器就不会自动生成默认构造函数,导致编译失败。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
template<typename T1, typename T2>
class test{
public:
    T1 _a;
    T2 _b;

    test(T1 a, T2 b) : _a(a), _b(b) {}

    void showitem(){
        cout << this->_a << "," << this->_b << endl;
    }


};

template<typename T1, typename T2, typename T3, typename T4>
class test1 : public test<T1, T2>{ //!继承模板父类,子类父类都要是模板类。而且要显式指定父类模板类型。
public:
    // T1 _a;   //!如果子类也有自己的_a 和_b变量的话 那就不能调用父类构造函数赋值。
                //!想一下,子类自己的东西怎么可能让父类构造函数赋值?
                //@所以调用父类构造函数赋值其实相当于给子类的父类成分赋值。
    // T2 _b;
    T3 _c;
    T4 _d;
    //test1(T3 a, T4 b) : _a(a), _b(b) {}
    test1(T1 a, T2 b, T3 c, T4 d) : test<T1, T2>(a,b),_c(c),_d(d) //调用父类构造函数赋值 //!记得调用模板类父类构造函数的时候要显式指定数据类型
    {
        //!可以用构造列表也可以普通构造函数
        // _c = c; 
        // _d = d;
    }
    void showitem(){
        cout << this->_a << "," << this->_b << "," << this->_c << "," << this->_d<< endl;
    }

};



int main(){
    test<int,int>* b = new test<int, int>(5,5);
    b->showitem();
    test1<int, int, char, char>* c = new test1<int, int, char, char>(1,2,'a','b');
    c->showitem();
    
    //指针和对象都可以
    test<int,int> bb(5,5);
    bb.showitem();
    test1<int, int, char, char> cc (1,2,'a','b');
    cc.showitem();
}

普通类的子类调用父类构造函数长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class A {
    public:
        A(){}
        A(int a) :m_a(a) {}
        virtual~A(){}
    int m_a;
};

class B : public A{
    public:
        B(){}
        B(int x):m_b(x){}
        B(int no, int x):A(no),m_b(x){} //子类调用父类构造。必须用初始化列表。
        ~B(){}
        int m_b;
};

int main(){
    B b(5,8);
    cout << b.m_a << endl;
    
    return 0;
}

模板类 类模板 成员函数 类外实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
template<typename T1, typename T2>
class test{
    T1 _a;
    T2 _b;
    test(T1 a){} //构造1
    test(T1 a, T2 b){} //构造2
    void func1(); //无参函数1
    void func2(T1 a); //有参函数2
};

/*
类模板 成员函数 类外实现
头部要加模板
作用域部分要显式指明数据类型
*/
template<typename T1, typename T2>
test<T1, T2>::test(T1 a) : _a(a){} //构造1 使用初始化列表


template<typename T1, typename T2> 
test<T1, T2>::test(T1 a, T2 b){ //构造2
    _a = a;
    _b = b;
}

template<typename T1, typename T2>
void test<T1, T2>::func1(){ //无参函数1
    cout <<"func1" << endl;
}

template<typename T1, typename T2>
void test<T1, T2>::func2(T1 a){ //有参函数2
    cout <<"func2" << endl;
}

万能引用相关

参见杂记。

auto

  • auto 变量必须在定义时初始化,这类似于const关键字。
  • 定义在一个auto序列的变量必须始终推导成同一类型。例如:
1
2
auto a4 = 10, a5 = 20, a6 = 30;//正确
auto b4 = 10, b5 = 20.0, b6 = 'a';//错误,没有推导为同一类型
  • 如果初始化表达式是引用,则去除引用语义。
1
2
3
4
5
6
7
8
int a = 10;
int &b = a;
 
auto c = b;//c的类型为int而非int&(去除引用)
c = 100;
cout << c << endl; //100
cout << b << endl; //10
cout << a << endl; //10

我们看到了即使ba的引用,我们使用b初始化c的时候依旧使用了值语义。也就是c是一个独立变量。更改c的值不影响ab

1
2
3
4
5
6
7
8
int a = 10;
int &b = a;

auto &c = b;//c的类型为int&
c = 100;
cout << c << endl; //100
cout << b << endl; //100
cout << a << endl; //100
  • 如果初始化表达式为const或volatile(或者两者兼有),则除去const/volatile语义。
1
2
3
4
5
const int a1 = 10;
auto  b1= a1; //b1的类型为int而非const int(去除const)
const auto c1 = a1;//此时c1的类型为const int
b1 = 100;//合法
c1 = 100;//非法
  • 如果auto关键字带上&,则不去除const语意。
1
2
3
const int a2 = 10;
auto& b2 = a2;//因为auto带上&,故不去除const,b2类型为const int
b2 = 10; //非法
  • 初始化表达式为数组时,auto关键字推导类型(退化)为指针。
1
2
3
int a3[3] = { 1, 2, 3 };
auto b3 = a3;
cout << typeid(b3).name() << endl; //int *
  • 若表达式为数组且auto带上&,则推导类型为数组类型
1
2
3
int a7[3] = { 1, 2, 3 };
auto & b7 = a7;
cout << typeid(b7).name() << endl; //int[3]
  • 时刻要注意auto并不是一个真正的类型。
    • auto仅仅是一个占位符,它并不是一个真正的类型,不能使用一些以类型为操作数的操作符,如sizeof或者typeid

所以我们才会有auto& , auto&&const auto,const auto&等等。还可以有auto*,也可以被cv修饰

  • 注意在使用增强for循环遍历容器元素时,如果声明为auto则最好变成const auto& 防止修改和拷贝。
    • 使用增强for循环遍历容器元素时,auto的类型是容器内元素的类型。所以需要加const&

auto&&是万能引用

格外注意:auto&&是万能引用,除了从花括号包围的初始化器列表推导时除外

1
auto&& z = {1, 2, 3};//这个不是万能引用。(初始化器列表的特殊情形)

其他细节参见列表初始化。

C++17 复制省略技术

  1. 返回值优化(RVO),即通过将返回值所占空间的分配地点从被调用端转移至调用端的手段来避免拷贝操作。返回值优化包括具名返回值优化(NRVO)与无名返回值优化(URVO),两者的区别在于返回值是具名的局部变量还是无名的临时对象。

  2. 右值拷贝优化,当某一个类类型的临时对象被拷贝赋予同一类型的另一个对象时,通过直接利用该临时对象的方法来避免拷贝操作。

    • 在一个变量的等号右侧是 临时变量(prvalue) 的时候,这里会用 direct-initalize,而不是尝试使用 copy/move initialize。对于一些没有拷贝构造或移动构造的对象,如unique_ptratomicstd::array等可以使用等号初始化了。但是前提是右侧是一个临时对象以满足右值拷贝优化。

所以

1
2
auto a = atomic<int>{9};  //c++14 error, C++17 OK
atomic<int>s = 4;//c++14 error, C++17 OK

注意一个常见误区

1
2
3
4
5
6
7
8
9
10
11
std::unique_ptr<int> func(int x){
    std::unique_ptr<int> p = std::make_unique<int>(x);
    return {p};

}

std::unique_ptr<int> func2(int x){
    std::unique_ptr<int> p = std::make_unique<int>(x);
    return p;

}

func2可以而func1编译错误的原因是因为编译器试图将 p拷贝到一个新的 std::unique_ptr<int> 中。然而,由于 std::unique_ptr 不允许拷贝,所以编译器会报错,提示尝试使用被删除的拷贝构造函数。而func2是使用了移动构造。所以这里和RVO其实没有关系。当然了,如果可行的话编译器会把它优化为NRVO.

gcc14引入 -Wnrvo 帮助提醒优化返回值

RVO是编译时优化

## 理解RVO语义

进行复制消除时,实现将被省略的复制/移动 (C++11 起)操作的源和目标单纯地当做指代同一对象的两种不同方式,而该对象将在假如不进行优化时两个对象中后被销毁的对象销毁时销毁(但如果被选择的构造函数的形参是对象类型的右值引用,那么该销毁发生于目标对象本应被销毁时) (C++11 起)。

注意这句话:源和目标单纯地当做指代同一对象的两种不同方式

如何理解呢?

1
2
3
4
5
6
7
8
myobj func2(bool s){
    myobj d {2};
    if(s){
        return d;
    }
    d.val = new int(3);
    return d;
}

比如这段代码,myobj只会被构造一次。原因是编译器发现无论进行何种操作,我们返回的都是d,那么此时可以进行复制消除。也就是返回出去的返回值其实和d是同一个实例。有点类似于引用的关系。这种就是NRVO。请注意,在这种情况下,T必须是有可访问的复制或移动构造函数,即使最终没有调用实际的复制或移动构造函数。

1
2
3
4
5
6
7
8
myobj func(bool s){
    myobj d {2};
    if(s){
        return d;
    } else {
        return myobj{3};
    }
}

这段代码,一定会进行一次构造,而且一定会有一次拷贝或移动。因为d一定会被构造,但是具体是返回d还是一个新的临时对象是完全运行时确定的。编译器无法推测。所以这种情况下,要么是构造+移动,(这里的NRVO是不生效的)要么是构造+构造。(这里的第二次构造临时对象是直接构造在返回值里的。所以RVO是生效的。)

1
2
3
4
5
6
7
myobj func2(bool s){
    if(s){
        return myobj{3};
    }
    myobj d {2};
    return d;
}

这段代码变得奇怪了。其实是和上一段代码反过来写。但是这次,如果strue, 则RVO生效。所以只有一次构造。

这一部分可以看一下这篇文章

stringstream

我们可以使用stringstream方便的在string和其他内置类型之间进行转换。

  • 首先要#include<sstream>
  • 然后需要有一个stringstream 对象stringstream ss
  • 我们可以把字符串使用流插入运算符<<把要转换的字符串插入stringstream 对象
  • 使用流提取运算符>>把转换完成的对象提取至对应类型的变量内
  • 然后记得把stringstream 对象清空。
  • 支持负数自动转换。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
vector<int> process(vector<string>& input){
    vector<int> ret;
    stringstream ss; //stringstream对象
    for(int i = 0; i < input.size(); i++){
        ss << input[i]; //要转换的东西插入对象
        int a;
        ss >> a; //提取至我们想要的对象
        ss.clear(); //清空stringstream对象
        ret.push_back(a);
    }
    return ret;
}
int main(){
    vector<string> s{"1","2","200","-20","-10000"};
    vector<int> ret = process(s);
    for(int i = 0; i < ret.size(); i++){
        cout << ret[i] << endl;
    }
    return 0;
}

我们还可以使用stringstream进行默认分割。此处是根据空格,tab和回车换行分割

1
2
3
4
5
6
7
8
9
10
11
12
13
void split(const string& str){
    vector<string> item;
    stringstream ss(str); //字符串放入stringstream对象
    string temp; //储存临时分割对象
    while(ss >> temp){ //直到字符耗尽
        item.emplace_back(temp); //放入结果数组。
    }

    for(auto& i:item){
        cout << i << endl;
    }

}

我们还可以使用stringstream搭配getline进行自定义分割。

1
2
3
4
5
6
7
8
9
10
11
12
void split(const string& str, const char& splitter){
    vector<string> item;
    stringstream ss(str);
    string temp;
    while(getline(ss, temp, splitter)){
        item.emplace_back(temp);
    }
    for(auto& i:item){
        cout << i << endl;
    }

}

std::initializer_list

搭配聚合初始化一起看。

  • initializer_list会在下列情况自动构造:

    • 用花括号初始化器列表列表初始化一个对象,其中对应构造函数接受一个 std::initializer_list 参数

      • 这句话的意思就是使用{}初始化对象并且有匹配initializer_list的构造函数
    • 以花括号初始化器列表为赋值的右运算数,或函数调用参数,而对应的赋值运算符/函数接受 std::initializer_list 参数。

      • 这句话的意思就是使用{}初始化对象并且有匹配initializer_list的函数形参
    • 绑定花括号初始化器列表到 auto,包括在范围 for 循环中

      • 这句话的意思就是我们说的 auto在遇到一个用花括号初始化的对象的时候始终会认为其是一std::initializer_list

      • 1
        2
        3
        4
        5
        
        auto t = {1,2,3,4}; //t会被推导为initializer_list
              
        for(auto x:{1,2,3,4}){ //这里的{1,2,3,4}也是initializer_list
            //...
        }
        
  • 在我们使用大括号进行列表初始化的时候,如果我们有显式的符合对应条件的列表初始化构造函数则会优先匹配。

    • 如果我们使用了大括号进行列表初始化,则编译器会强烈的,尽可能的匹配至形参为initializer_list的构造函数。如果不匹配则会继续查找其他的构造函数。但凡可以匹配至initializer_list,编译器就会使用这个。
    • 在构造函数重载决议期间,只要有任何可能,大括号初始化物就会和带有std: : initializer_ list 型别的形参相匹配,即使其他重载版本有着貌似更加匹配的形参表
    • !!!!!!这里有个大坑!!!!下面说!!!!
  • 注意列表初始化只能使用相同类型或可以被转换为相同类型的参数。
  • 匹配initializer_list构造函数必须使用{}
  • 如果有默认构造函数就算有initializer_list构造函数,但是如果对象构造时入参为空,就算使用了{}也优先匹配默认构造函数。除非没有默认构造函数。

    • 语言规定,形如T obj{}这样的情况下,应该执行默认构造。因为空的大括号表示的是没有实参,而不是空的initializer_list
    • 如果确实想要表达出空的initializer_list的含义,可以使用T obj({})
    • 如果没有默认构造函数,则会匹配initializer_list构造函数。也就是构造对象时就算没有参数要输入也必须加{}
  • initializer_list构造函数拥有高优先级。但是默认构造函数(default constructor)拥有最高优先级
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class myclass{
    public:
        myclass(){
          cout <<"default" << endl;
        };
                
        myclass(int x, int y):val1(x), val2(y){
            cout <<"cont0" << endl;
        }
        myclass(int x, int y, int z):val1(x), val2(y), val3(z){
            cout <<"cont1" << endl;
        }
        myclass(int x, int y, int z, const string& s):val1(x), val2(y), val3(z), s_m(s){
            cout <<"cont2" << endl;
        }
        myclass(std::initializer_list<int>list){
            cout <<"initializer_list" << endl;
        };

        myclass(const myclass& rhs){
            cout << "copy const" << endl;
        }

        operator int(){ //用户定义转换函数
            cout <<"user define conversion" << endl;
            return 0;
        }

        int val1;
        int val2;
        int val3;
        string s_m = "";
};




int main(){
    myclass obj0{1,2};		//initializer_list 因为使用了大括号
    myclass obj1{1,2,3};	//initializer_list 因为使用了大括号
    myclass obj2{1,2,3, "abcd"};//cont2 虽然使用了大括号但是类型不匹配,所以转而匹配普通的符合条件的构造函数。


    myclass obj4(1,2);	//cont0 因为没有使用大括号
    myclass obj5(1,2,3);//cont1 因为没有使用大括号
    myclass obj6(1,2,3,"abcd");//cont2 因为没有使用大括号
  
  
  	myclass obj7; //default 注意不能加小括号。那是函数声明。
    myclass obj8{}; //default。就算有initializer_list构造函数,但是如果创建对象是无参的也优先匹配默认无参构造函数因为他有最高优先级。除非没有默认无参构造函数。
    myclass obj9({}); //initializer_list。因为小括号套了大括号,表达出空的initializer_list意愿。


    myclass obj10(obj7); //copy const 无需解释
    myclass obj11{obj7}; //大坑。先user define conversion,再initializer_list
    return 0;
}

这里就是刚才说的大坑。注意obj10obj11。匹配至initializer_list的意愿如此强烈,以至于obj11是先使用了用户定义转换函数转换为int,然后匹配至initializer_list构造函数。

  • 特别的,如果initializer_list构造函数被explicit修饰,则也需遵照其explicit禁止隐式类型转换的规则。
  • 如果构造函数所有参数都有默认值,这个构造函数就成了默认构造函数
    • 默认构造函数就是要么没有参数,要么所有参数都有默认值。
1
2
3
4
5
6
7
8
9
10
11
12
13
class obj{
    public:
        explicit obj(int a = 10, int b = 20):val1(a), val2(b){}; //禁止隐式类型转换。
    int val1;
    int val2;
};
int main(){
    obj t1; //可以。有默认构造函数。
    obj t2{10,20}; //可以,explicit只能限制拷贝初始化。这里是直接初始化。
    obj t3 = {10,20}; //不可以,禁止隐式类型转换。
    obj t4 = obj{10,20}; //可以,显式类型转换。
    return 0;
}
  • 注意,当一个类中不仅含有普通构造函数,还含有initializer_list构造函数,那么这个类在使用(){}初始化的时候会有不同的含义。比如vector。例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class myobj{
    public:
        myobj(int x, int y){
            inner_vec.resize(x, y); //可能会有更优雅的解决方案。
        }
        myobj(initializer_list<int>my_list){
            for(auto& j:my_list){
                inner_vec.emplace_back(j);
            }
        }
        vector<int> inner_vec;
};



int main(){
    myobj ob1(2,3); //两个值为3的元素。
    for(auto& i:ob1.inner_vec){
        cout <<i << endl;
    }
    cout <<"----------" << endl;
    myobj ob2{1,2,3,4,5,6,7}; //把元素1,2,3,4,5,6,7放入vector
    for(auto& i:ob2.inner_vec){
        cout <<i << endl;
    }
    return 0;
}

原理分析

  • 别他妈瞎研究了。魔法就完了。可以看看它的构造函数长啥样你就知道了。推测是当用{}进行初始化的时候,首先会创建一个array,并将初始化元素存放起来。然后,调用initializer_list的构造函数,用array首元素的迭代器和array的元素个数,进行初始化。(https://blog.csdn.net/xiangbaohui/article/details/103609076)。这是汇编层面了。

  • 除此之外,还有如下几个注意点:

    • initializer_list是一个轻量级的容器类型,内部定义了iterator等容器必需的概念。

      • 其中有3个成员接口:size()begin()end()。遍历时取得的迭代器是只读的,无法修改其中的某一个元素的值;
    • 对于initializer_list而言,它可以接收任意长度的初始化列表,但要求元素必须是同种类型T(或可转换为T);

    • initializer_list会复制初始化传入的元素至底层的数组。这就是(无编译器优化时)额外开销的来源。但是多个复制的std::initializer_list共用底层数组空间。

      • 上面这句话比较难理解。意思是虽然std::initializer_list会复制初始化传入的元素,但是复制一个 std::initializer_list 不会复制其底层对象。只会复制其内部的几个指针而已。

      • 再次注意,上面加粗的部分是两句话。

        • 1:std::initializer_list会复制初始化传入的元素。所以它是拷贝进去,然后拷贝出去。开销较大。来自
        • 2:复制一个 std::initializer_list 不会复制其底层对象。只会复制其内部的几个指针而已
        • 所以std::initializer_list 一个严重问题之一是元素不适用于仅移动类型。比如std::unique_ptr
      • 底层数组是 const T[N] 类型的临时数组,其中每个元素都从原始初始化器列表的对应元素复制初始化(除非窄化转换非法)。底层数组的生存期与任何其他临时对象相同,除了从数组初始化 initializer_list 对象会延长数组的生存期,恰如绑定引用到临时量(有例外,例如对于初始化非静态类成员)。底层数组可以分配在只读内存。

      • 因此,通过拷贝构造对象与原对象共享列表中的元素空间。也就是说,initializer_list的内部并没有内含该array的内容,仅仅是拥有指向array的迭代器。如果对这个容器进行拷贝构造或者拷贝赋值的话,array的内容只有一份,但有两份迭代器指向同一片位置。如果对initializer_list对象copy一个副本,默认是浅拷贝,此时两个对象指向同一个array。这是危险的。
  • 注意std::initializer_list底层是常量数组 const T[N]。所以不能直接修改其存储的元素。返回的引用也是常量引用。迭代器也是常量迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
std::initializer_list<int> func(void){
  int a = 1, b = 2;
  return {a, b};      //a与b是局部变量在func返回后会被释放,但是复制的返回值initializer_list不会创建元素副本。而是和临时对象共享空间。所以
                      //func返回后会被释放,initializer_list内部会存在空悬引用!危险!
                      //正确的做法可以将返回值改为保存副本的容器,如vector<int>
}

//注意下面s1、s2、s3和s4均共享元素空间
initializer_list<string> s1 = { "aa", "bb", "cc", "dd" };
initializer_list<string> s2 = s1;
initializer_list<string> s3(s1);
initializer_list<string> s4;
s4 = s1;

//注意元素会被复制初始化
int a = 200;
int b = 300;
int c = 400;
initializer_list<int> mylist{ a,b,c };
a = 800;
cout << *mylist.begin() << endl; //200
cout << a << endl; //800

额外注意initializer_list会导致额外开销。但部分可以被编译器优化。

https://stackoverflow.com/questions/75291691/why-stdinitializer-list-will-cause-overhead-when-use-it-to-initialize-a-vector

这东西非常看编译器。不建议深入研究。比如Clang就没第二个move。

1
2
3
4
5
6
7
8
9
10
11
vector<myobj> a{myobj(20)};
/*
const //构建myobj临时对象
mv //这个移动是因为vector有一个构造函数接受std::initializer_list。所以外部的{}花括号初始化器beace-init-list会把它当成std::initializer_list。这个move是为了创建这个std::initializer_list。
mv //这个移动是为了把我们刚才构建好的std::initializer_list值传递给vector的构造函数。可能因为传入的是右值则此时触发移动构造。
copy const //从initializer_list拷贝至容器内。这个是另一个开销。因为std::initializer_list永远复制进来复制出去。所以这个地方不是mv而是copy const。
dest
dest
dest
*/
std::vector<myobj, std::allocator<myobj> > a = std::vector<myobj, std::allocator<myobj> >{std::initializer_list<myobj>{myobj(myobj(myobj(20)))}, std::allocator<myobj>()}; //cpp insight 展开后长成这样。不知道为何有那一大串myobj
  • 不使用initializer_list,没有额外开销。
1
2
3
4
5
6
7
8
9
myobj obj(20);
vector<myobj> a;
a.push_back(obj);
/*
const //构造myobj对象
copy const //拷贝构造至容器。
dest
dest
*/

https://akrzemi1.wordpress.com/2016/07/07/the-cost-of-stdinitializer_list/

指针A给指针B赋值的意思是将指针A指向的地址赋给指针B

1
2
3
4
5
6
7
8
9
10
11
int digit = 5;
int* a = &digit;
int* b = a; //指针a指向的地址赋给指针b 没有新对象产生所以不会调用拷贝构造。
int* c = &*a; //等同于将指针a先解引用(*a = 5),得到指针a指向的的值。然后把这个值取地址(&*a)赋值给指针c。

cout << b << endl; //打印b储存的地址。也就是b指向的地址。 也就是变量地址。等同于&digit 和&*b
cout << &b << endl; //打印b自己的地址。
cout << &*b << endl; //打印b指向的地址的值的地址。等于直接打印指针。
cout << &digit << endl; //打印变量地址。等于直接打印指针和&*b
cout << *b << endl; //解引用b 打印b指向的地址的值。

指针加法

指针直接和数字相加:意思是指针当前指向的地址 + 对应指针类型的大小 * 数字。

举例:

1
2
3
4
5
int * p;
p = (int*)malloc(100); //分配100字节。
cout << p<< endl; //打印p储存的地址。也就是指针指向的地址。是内存首地址。此处输出 0x771410
auto y = p + 1; //把p指向的地址偏移4位(因为int是4字节)后赋值给y。
printf("%#x",y); //打印y储存的地址。也就是指向的地址。此处输出 0x771414

也就是说指针直接和数字相加,可以理解为用指针操作数组。加几就是将指针往后移动几位。具体偏移量也就是对应对象的大小*移动位数。

如果想要指定一个具体的偏移量应该怎么办?

首先,可以使用转型将指针转换为对应类型。然后再加数字。

举例:

1
2
3
4
5
6
7
obj* p;
p = (obj*)malloc(sizeof(int) * 10);
cout << p<< endl; //打印p储存的地址。也就是指针指向的地址。是内存首地址。 此处输出 0x771410
auto x = (char*)p + 1; //把p指向的地址偏移1位后赋值给x。因为char是1位。
printf("%#x\n",x); //打印x储存的地址。也就是指向的地址。此处输出 0x771411
auto y = (obj*)p + 1; //把p指向的地址偏移8位后赋值给x。因为obj是指针类型。8位。
printf("%#x\n",y); //打印y储存的地址。也就是指向的地址。此处输出 0x771418

注意:指针类型转换指的是指针转换为对应类型的指针。

此处不要使用cout

auto x = (char*)p的意思是把指针p转为char*类型的指针。不可以auto x = (char)p。 这样做的意思是直接转换成了char。当然,所有的指针都是8字节。记住,指针类型指的是告诉指针应该读取指针指向的地址之后多大的数据。

怎么理解这个操作呢。我们可以想一下。如果 aint*类型,也就是int类型指针。意思是指针a指向的数据是int,所以需要以4字节为一段进行读取。也就是一次读取四个字节。那么a+1其实是对地址进行了+4操作。因为指针+1的意思是将指针往后移动一个对象位。也就是指针当前指向的地址 + 对应指针类型的大小(此处为4) * 数量(此处为1)。但是我们如果想要对地址+1的话怎么办?我们可以把指针a转换为char*类型也就是char类型指针。而不是int类型指针。这样相当于告诉编译器,指针a指向的数据是char。所以需要以1字节为一段进行读取。也就是一次读取一个字节。所以此时a+1就是对地址进行了+1操作。也就是指针当前指向的地址 + 对应指针类型的大小(此处为1) * 数量(此处为1)。

移动一个对象位也就是移动的偏移量为对象大小。

1
2
3
4
5
int digit = 5;
int* a = &digit;
cout << a << endl;
auto x = (char*)a + 1;
printf("%#x\n",x);

注意!(char*)a + 1往后移动1位的时候,(char*)a的意思是把指针a看成指向char类型的指针(告诉编译器一段是1)。所以(char*)a + 1不是+2,还是+1。因为指针a指向的地址根本没变。仅仅是看成了char类型的指针后+1

函数入参为指针的时候,指针本身会有浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void func(int* a){
    cout << &a << endl; //打印指针的地址
    cout << a << endl;  //打印指针指向的变量的地址
}

int main(){
    int* ptr = new int(5);
    cout << &ptr <<endl; //打印指针的地址
    cout << ptr << endl; //打印指针指向的变量的地址
    func(ptr);
}
/*
输出:
0x61fe18 //指针地址
0x1b1490 //变量地址
0x61fdf0 //指针地址
0x1b1490 //变量地址
*/

  • 我们可以看到,指针入参后,指针本身会发生拷贝。会发生指针给指针赋值。也就是新建了一个指针指向了同一个地址,也就是浅拷贝。所以此时会有两个指针指向同一个地址。一个在func函数内,一个在main里。

函数指针 和 回调函数

  • 函数指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef int(*ptr)(int, int); //typedef 定义别名
ptr instance = add; //方法一 函数外直接生成示例并赋值。注意这里typedef之后直接把函数赋值过去即可。
ptr test1;
test1 = add; //这里是错的。函数体外只能进行全局函数和变量的声明,而无法执行语句或调用函数。

int add(int a, int b){ //不需要传入函数指针了。
    cout << a+b << endl;
    return (a+b);
}

int main(){
    ptr test; //方法二 函数内进行分离声明
    test = add;
    test(5,8);
}
  • 回调函数
1
2
3
4
5
6
7
8
9
10
11
12
13
int add(int a, int b){ 
    cout << a+b << endl;
    return (a+b);
}

void callback(int(*ptr)(int,int), int a, int b){ //设立callback函数。传入函数指针和参数。
  注意这里如果需要有值传出就要设置为返回int
    ptr(a, b);
}

int main(){
    callback(add, 5, 8);
}
  • 回调函数 c++风格
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void callback(int(*ptr)(int, int), int a, int b){ //回调函数 如果需要返回值就return
    ptr(a, b);
}


class test{
    public:
        static int add(int a, int b){ //需要被执行的函数。注意要static。要么就全局。
            cout << a+b << endl;
            return (a+b);
        }
        void registure(){ //注册函数
            callback(add, 3, 5);
        }
        
};

int main(){
    test Test;
    Test.registure();
    return 0;
}
  • 回调函数 c++多态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class basecase{
    public:
        virtual void testmsg() = 0;
        virtual ~basecase(){}; //虽然这里没什么卵用但是还是要记得虚析构,不然无法执行子类析构
};

class case1 : public basecase{ //继承
    public:
        void testmsg() override{
            cout << "Case1 coutmsg" << endl;
        }
};

class case2 : public basecase{ //继承
    public:
        void testmsg() override{
            cout << "Case2 coutmsg" << endl;
        }
};

int main(){
    basecase* test1 = new case1();
    basecase* test2 = new case2();
    test1->testmsg();
    test2->testmsg();
    delete test1; //养成良好习惯
    delete test2;
    return 0;
}

注意c++中的类回调函数必须是静态函数或者是全局函数。

一般来说为了封装性质,我们需要把一个函数放入类内。但是调用类成员函数需要加this,也就是要通过对象调用。但我们不想这样做。于是想舍弃这个this的话就应该设置为static静态函数。静态函数就像一个全局函数一样,也就是带作用域的全局函数。满足我们的需求。

静态函数只能访问静态对象,想访问非静态数据怎么办?

我们可以把回调函数的入参设置为一个类对象的指针或者是一个void*类型的指针。然后通过这个入参来调用。

  • 例子1:传入类对象指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class A{
    public:
    static void callback(A *pThis); //静态函数
    void b(){ //非静态函数
        cout <<"b" << endl;
    }        
}; 
 
void A::callback(A *pThis) //入参为类类型指针
{
    pThis->b(); //静态函数中调用非静态函数
}
int main(){
    A* aptr = new A();
    A::callback(aptr); //指针传入回调函数。
    delete aptr;
    return 0;
}
  • 例子2:传入this指针
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A{
    public:
    static void callback(A *pThis); //静态函数
    void b(){ //非静态函数
        cout <<"b" << endl;
    }       
    void in_class(){
        callback(this); //直接传入this
    } 
}; 
 
void A::callback(A *pThis)
{
    pThis->b(); //静态函数中调用非静态函数
}
int main(){
    A* aptr = new A();
    aptr->in_class();
    delete aptr;
    return 0;
}

例子3:使用void*类型做为入参后强转。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class A{
    public:
    static void callback(void *pThis); //静态函数
    void b(){ //非静态函数
        cout <<"b" << endl;
    }       
    void in_class(){
        callback(this); //直接传入this
    } 
}; 
 
void A::callback(void *pThis)
{
    ((A*)pThis)->b(); //静态函数中调用非静态函数。强转
}
int main(){
    A* aptr = new A();
    aptr->in_class();
    delete aptr;
    return 0;
}

函数类型,函数指针类型和函数指针的区别

  • 函数指针指向的是函数而非对象。和其他指针类型一样,函数指针指向某种特定类型。
  • 函数类型由它的返回值和参数类型决定,与函数名无关。
  • 函数指针类型就是函数指针的类型。
1
bool func(const string& a, const string& b);

上述函数类型是:bool (const string &, const string &); 函数指针类型是:bool (*)(const string &, const string &);

我们提到过,函数名称在使用时会被自动转换为函数指针。也就是:

1
2
3
fptr_name = func 
    等价于 
fptr_name = &func
1
2
3
4
bool Func(const string&, const string&); // Func是函数类型;
bool (*FuncP)(const string&, const string&); // FuncP是函数指针类型;
typedef decltype(Func) Func2;  // Func2是函数类型;
typedef decltype(FuncP) *Func2P; // Func2P是函数指针类型;

注意decltype(Func)返回的是函数类型,而不是函数指针类型;

  • 下面两个声明语句是同一个函数,因为编译器会自动的将Functoin_Type 转换成函数指针类型。
1
2
void test(int a, int b, Functoin_Type  fn);
void test(int a, int b, Funcion_Pointer fn);

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using Functoin_Type = int(float,int); //函数类型
using Funcion_Pointer = int(*)(float, int); //函数指针类型


void callback1(Functoin_Type callable){ //函数类型当形参。自动转换
    callable(1.2f,2);
}
void callback2(Funcion_Pointer callable){ //函数指针当形参。
    callable(1.2f,2);
}
int testsor(float b, int c){
    cout << b << endl;
    cout << c << endl;
    cout <<"success" << endl;
    return 1;
}
int main(){
    // test<int(float, int)> obj;
    // obj.construct(testsor, 2.5f, 234);
    callback1(testsor); //效果一致
    callback2(testsor); //效果一致
    return 0;
}
  • 做为返回值时,由于返回的必须是函数指针类型而非函数类型,所以
1
2
3
4
5
6
Functoin_Type ret1(){
 //错误。返回的是函数类型
}
Funcion_Pointer ret2(){
    //正确。返回的是函数指针类型。
}

函数类型有一个非常特殊的地方:不能使用函数类型声明变量,但是可以把它当做函数的形参(编译器会自动转换)

https://zh.cppreference.com/w/cpp/language/function

形参列表部分:

….

形参列表中的每个函数形参的类型根据下列规则确定:

3) 如果类型是函数类型 F,那么它被替换成类型F 的指针”

1
2
Functoin_Type f = testsor;      //错误 不可使用函数类型声明变量。
Funcion_Pointer f = testsor;    //正确
  • 成员函数指针类型
1
2
3
4
5
6
7
class obj{
    public:
        bool func(int, int){
            std::cout <<"called" << endl;
        }
};
cout <<is_same<bool(obj::*)(int, int), decltype(&obj::func)>::value << endl; //true
  • 注意,函数指针类型是一个整体。不能把他们分开。比如函数指针bool (*pf)(int) 的类型是 bool (*)(int)。在模板接受一个参数的时候,不能把返回值类型bool扒下来。
  • 当然了,如果想,也可以利用成员函数指针类型的方法把他们分开。
1
2
3
4
5
template<typename R, typename T, typename... Args>
void f(R(T::*pf)(Args... args)){
        std::puts(__PRETTY_FUNCTION__);
}
//void f(R (T::*)(Args ...)) [with R = bool; T = obj; Args = {int, int}]

https://www.jianshu.com/p/6ecfd541ec04

https://stackoverflow.com/questions/17446220/c-function-types

https://stackoverflow.com/questions/13233213/can-a-function-type-be-a-class-template-parameter

https://stackoverflow.com/questions/72926596/type-deduction-for-a-member-function-pointer

函数引用

和函数指针,函数名没啥区别。主要遇到的地方是杂记4中的decltype的函数名做为表达式的部分。剩下的看下面的链接即可。

https://stackoverflow.com/questions/19200513/function-pointer-vs-function-reference

本文由作者按照 CC BY 4.0 进行授权

c++并发编程实战-笔记

设计模式