今天是学习极客时间《C++11 多线程编程》 B站教程的第一天。

专题

C++语言到底怎么学?

程序非常紧,务必用最少的时间,学最好的之后去赚钱。

  1. 入门:

关于异常的理解:出现异常就应该崩,不要带病工作。

一、智能指针

1. 智能指针和proxy

为了更安全的使用动态内存(堆内存),C++11 提供了两个智能指针。

智能指针的本质是中间层。

在 proxy 中维护一个引用计数器。

P1 指向 proxy 时,让 proxy 里的计数计数 + 1.

2. 智能指针也是模板,默认初始化的智能指针中保存着一个空指针

shared_ptr p1;

3. unique_ptr的使用

  1. 不要用同一个裸指针初始化多个 unique_ptr 对象

AA* p = new AA(“西施”);
unique_ptr pu(p);

p、pu、pu.get() 打印的地址是一样的。

  1. unique_ptr 传参时不能传值,只能传引用。

4. share_ptr

当新的 shared_ptr 与对象关联时,引用计数 + 1。

  1. 请教一个C++问题,当新的 shared_ptr 与对象关联时,引用计数 + 1。这个引用计数是哪个数据结构实现的?谁在进行计数的统计工作?

在C++中,std::shared_ptr是一个智能指针,它负责管理一个对象的共享所有权。shared_ptr内部使用一个名为“控制块”的数据结构来存储引用计数。控制块通常包含两个计数器:一个是共享引用计数(shared reference count),另一个是弱引用计数(weak reference count)。

当创建一个新的shared_ptr并与某个对象关联时,控制块中的共享引用计数会加1。当shared_ptr的拷贝被创建或销毁时,共享引用计数会相应地增加或减少。当共享引用计数变为0时,shared_ptr会自动删除所指向的对象。控制块也会在共享引用计数和弱引用计数都为0时被删除。

在C++中,shared_ptr的实现是库实现的一部分,通常由标准库提供。因此,具体的引用计数统计工作是由标准库中的shared_ptr实现来完成的。不同的编译器和标准库实现可能会有不同的实现细节,但核心概念是一致的。

  1. shared_ptr 本身数据结构是怎么样的?请给我用伪代码说明,并添加足够的代码注释

    // 控制块类,用于存储对象指针、共享引用计数和弱引用计数
    template
    class ControlBlock {
    public:

     // 构造函数,初始化对象指针、共享引用计数和弱引用计数
     ControlBlock(T* ptr) : object_ptr(ptr), shared_count(1), weak_count(0) {}
    
     // 增加共享引用计数
     void increment_shared_count() { ++shared_count; }
     // 减少共享引用计数
     void decrement_shared_count() { --shared_count; }
     // 获取共享引用计数
     int get_shared_count() const { return shared_count; }
    
     // 增加弱引用计数
     void increment_weak_count() { ++weak_count; }
     // 减少弱引用计数
     void decrement_weak_count() { --weak_count; }
     // 获取弱引用计数
     int get_weak_count() const { return weak_count; }
    
     // 获取对象指针
     T* get_object_ptr() const { return object_ptr; }
    
     // 当共享引用计数和弱引用计数都为0时,删除控制块
     void try_delete() {
         if (shared_count == 0 && weak_count == 0) {
             delete object_ptr;
             delete this;
         }
     }
    

    private:

     T* object_ptr; // 对象指针
     int shared_count; // 共享引用计数
     int weak_count; // 弱引用计数
    

    };

    template
    class shared_ptr {
    public:

     // 构造函数,创建新的控制块并关联对象指针
     explicit shared_ptr(T* ptr) {
         if (ptr) {
             control_block = new ControlBlock(ptr);
         } else {
             control_block = nullptr;
         }
     }
    
     // 拷贝构造函数,增加共享引用计数
     shared_ptr(const shared_ptr& other) {
         control_block = other.control_block;
         if (control_block) {
             control_block->increment_shared_count();
         }
     }
    
     // 析构函数,减少共享引用计数并尝试删除控制块
     ~shared_ptr() {
         if (control_block) {
             control_block->decrement_shared_count();
             if (control_block->get_shared_count() == 0) {
                 control_block->try_delete();
             }
         }
     }
    
     // 赋值运算符,更新引用计数和控制块指针
     shared_ptr& operator=(const shared_ptr& other) {
         if (this != &other) {
             if (control_block) {
                 control_block->decrement_shared_count();
                 if (control_block->get_shared_count() == 0) {
                     control_block->try_delete();
                 }
             }
             control_block = other.control_block;
             if (control_block) {
                 control_block->increment_shared_count();
             }
         }
         return *this;
     }
    
     // 访问对象成员函数
     T* operator->() const { return control_block->get_object_ptr(); }
     // 访问对象成员变量
     T& operator*() const { return *(control_block->get_object_ptr()); }
    

    private:

     ControlBlock<T>* control_block; // 控制块指针
    

    };

  2. 按你上面的代码,如果我new了一个新的对象存在堆中,我先将它赋值给shared_ptr1,然后再赋值给shared_ptr2,等到shared_ptr1触发析构函数的时候,不就把这个对象给delete了吗? 这样shared_ptr2怎么办?

首先,我要强调的是,直接使用new操作符创建的对象,然后将其赋值给shared_ptr实例是不推荐的。因为这样做可能导致内存泄漏。正确的做法是使用std::make_shared函数创建shared_ptr实例。

然而,针对您的问题,我将提供一个解释。假设您的代码如下:

T* obj = new T();
std::shared_ptr<T> shared_ptr1(obj);
std::shared_ptr<T> shared_ptr2(obj);

在这种情况下,shared_ptr1和shared_ptr2都将指向同一个对象。但是它们各自有独立的控制块,因此它们的引用计数是独立的。当shared_ptr1触发析构函数时,它会删除所指向的对象。这将导致shared_ptr2持有一个已经被释放的对象的指针。当shared_ptr2触发析构函数时,它将尝试再次删除该对象,导致未定义行为。

为了避免这种问题,您应该使用std::make_shared函数创建shared_ptr实例,或者在创建第二个shared_ptr时使用拷贝构造函数或赋值运算符,以确保它们共享相同的控制块和引用计数。

二、Lambda

也叫匿名函数,闭包,closure。

int N = 100, M = 10;

auto g = [N, &M](int i) {
// M 被引用捕获了,可以被改值
M = 20;
return N * i;
}

[&N, M] [M, &N] : 按引用捕获N,按值捕获M

[&, =M] [=, &N]

三、makefile

1. 简介

make 程序需要一个编译规则说明文件,称为makefile。

makefile 文件中描述了整个软件工程的编译规则和各个文件之间的依赖关系。

makefile 就像是 shell 脚本一样,其中可以执行操作系统的命令,它带来的好处就是我们能够实现”自动化编译”,使用make可以让重新编译的次数最小化。

2. 实战举例

当源文件非常多的时候,不想把所有类文件都重新编译,想使用 g++ main.cpp -c 去编写,如果改动到的cpp文件相当多,每次都执行g++就非常繁琐。

这时就要请出 makefile 管理和编译C++代码。

运行 make 命令会自动找文件下的 makefile 文件。

上面这个版本的问题,hello 指定的变量需要不断维护。

好处↑ 可以实现这3个文件只有单个编译变更时,只编译单个变化的文件。

clean 可以清楚掉旧编译产物。

3. 为什么拉到项目新代码后,要先clean再make?

因为从远端拉到的项目,make是识别不了文件是否变化的,所以需要先clean,然后make重新全量编译。

四、多线程

进程:运行中的程序。

线程:线程是进程中的进程。

并发和线程必须要掌握。

1. 线程库的基本使用

线程的最大数量取决于CPU的核数。

void printHelloWorld() {
std::cout << ‘helo world’ << std::endl;
return;
}

int main() {
// 1. 创建线程
std::thread thread1(printHelloWorld);

// 2, 主程序等待线程执行结束join
thread1.join();


// 3. 分离线程(主线程可以结束,子线程可以在后台继续运行)
//    用的不多,一般是配合多进程使用
thread1.detach();

// 4. joinable 判断线程是否可以调用join或者的attach
bool isJoin = thread1.joinable();
if (isJoin) {
    thread1.join();
}

return 0;

}

Tips: 小知识点: std::ref(x) 会将对象x转换成引用类型。

C++最大数量取决于CPU的数量,iPhone的CPU是6核的,这说明在iPhone上C++最多只能开辟6个线程吗?

C++中的线程数量并不受限于CPU的核心数量。实际上,您可以在任何具有多核CPU的系统(包括iPhone)上创建比核心数量更多的线程。操作系统负责在可用的CPU核心之间调度这些线程的执行。

当线程数量超过CPU核心数量时,操作系统会使用一种称为时间片轮转(time-slicing)的技术来在不同线程之间切换。这意味着每个线程会在一个核心上运行一小段时间,然后让其他线程继续执行。这种切换在用户看来是无缝的,因此给人一种多个线程在同时运行的错觉。

需要注意的是,虽然可以创建比CPU核心数量更多的线程,但这并不意味着线程的数量越多,性能就越好。实际上,过多的线程可能会导致上下文切换(context switching)开销变大,从而降低程序的性能。在设计多线程程序时,需要权衡线程数量和程序性能之间的关系。通常,线程数量接近或略高于CPU核心数量是一个较好的选择。这样可以充分利用多核CPU的并行能力,同时避免过多的上下文切换开销。

2. 互斥量解决多线程数据共享问题

(1)举例多线程同时读一个变量的问题

#include<iostream>
#include<thread>

int a = 0;

void func() {
    for (int i = 0 ; i < 100000; i ++) 
        a += 1;
}

int main() {
    std::thread t1(func);
    std::thread t2(func);

    t1.join();
    t2.join();

    std::cout << a << std::endl;
    return 0;
}


// 输出的结果不等于 200000 
// 因为两个线程可能同时访问到一个相同的数字

(2)加锁
#include
#include
#include

// 全局变量
int a = 0;
std::mutex mtx;

void func() {
    for (int i = 0 ; i < 100000; i ++)
    {
        // 加锁
        mtx.lock();
        a += 1;
        mtx.unlock();
    }
}

int main() {
    std::thread t1(func);
    std::thread t2(func);

    t1.join();
    t2.join();

    std::cout << a << std::endl;
    return 0;
}


// 输出的结果等于 200000 

如果多线程程序每一次的运行结果和单线程运行的结果始终是一样的,那么你的线程就是安全的。

3. 互斥量死锁

(1)互斥量演示

t1 等 t2 把 m2 放出来,t2 等 t1 把 m1 放出来。

#include<iostream>
#include<thread>
#include<mutex>

std::mutex m1,m2;

void func1() {
    for(int i = 0 ; i < 50; i +) {
        m1.lock();
        m2.lock();
        m1.unlock();
        m2.unlock();
    }
}

void func2() {
    for(int i = 0 ; i < 50; i +) {
        m2.lock();
        m1.lock();
        m2.unlock();
        m1.unlock();
    }
}

int main() {
    std::thread t1(func1);
    std::thread t2(func2);

    t1.join();
    t2.join();

    std::cout << a << std::endl;
    return 0;
}

(2)lock_guard 与 unique_lock

a. lock_guard

locstd::lock_guard 是 C++标准库 中的一种互斥量封装类,用于保护共享数据,防止多个线程同时访问同一资源而导致的数据竞争问题。

std::lock_guard 的特点如下:

  • 当构造函数被调用时,该互斥量会自动锁定
  • 当析构函数被调用时,该互斥量会被自动解锁。
  • std::lock_guard对象不能复制或移动,因此它只能在局部作用域中使用。

void func() {
    for (int i = 0 ; i < 100000; i ++)
    {
        // 加锁
        mtx.lock();
        a += 1;
        mtx.unlock();
    }
}


// 优化成:
void func() {
    for (int i = 0 ; i < 100000; i ++)
    {
        std::lock_guard<std::mutex> lg(mtx);
        a += 1;
    }
}

b. unique_lock

std::unique_lock : 加强版 std::lock_guard ,可以对互斥量进行更加灵活的管理,包括延迟加锁、条件变量、超时等。

  • lock() : 尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁。
  • try_lock_for(xx) : 尝试对互斥量进行加锁操作,如果当前互斥量已经被其它线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间。
  • try_lock_until() : 尝试对互斥量进行加锁操作,如果当前互斥量已经被其他线程持有,则当前线程会被阻塞,直到互斥量被成功加锁,或者超过了指定的时间点
  • unlock() : 对互斥量进行解锁

4. call_once 和使用场景

std::call_once 是 C++11 标准库中的一个函数,用于确保某个函数只会被调用一次。

确保多个线程中同时调用 call_once 时,只有一个线程能够成功执行函数,其它线程则会等待该函数执行完成。

用于单例模式,比如Log类。

static Log log == nullptr;
class Log {
public:
    Log() {}
    Log(const Log& log) = delete; // 禁用拷贝构造
    Log& operator-(const Log& log) = delete; // 禁用=

    static Log& GetInstance() {
        if(!log) log = new Log;
        return *log;
    }

    void PrintLog(std::string msg) {
        std::cout << __TIME__ << '  ' << msg << std::endl;
    }
}

上面代码在多线程调用时会出问题,可能会初始化多次单例。

可以使用 std::call_once 解决多线程调用问题。

static Log log == nullptr;
static std::once_flag once;
class Log {
public:
    Log() {}
    Log(const Log& log) = delete; // 禁用拷贝构造
    Log& operator-(const Log& log) = delete; // 禁用=

    static Log& GetInstance() {
        std::call_once(once,init);
        return *log;
    }

    static void init() {
        if(!log) log = new Log;
    }

    void PrintLog(std::string msg) {
        std::cout << __TIME__ << '  ' << msg << std::endl;
    }
}

5. condition_variable 和使用场景

(1)生产者-消费者模型

如果任务队列为空的时候,消费者会等待。

如果有任务的时候,要通知线程启动。

(2)condition_variable

    1. 创建一个 std::condition_variable 对象
    1. 创建一个互斥锁 std::mutex 对象,用来保护共享资源的访问
    1. 在需要等待条件变量的地方

      使用 std::unique_lockstd::mutex对象锁定互斥锁,并调用 std::condition_variable::wait() 或者 std::condition_variable::wait_for() 或 std::condition_variable::wait_until() 函数等待条件变量

    1. 在其他线程中需要通知等待的线程时,调用 std::condition_variable::notify_one() 或 std::condition_variable::notify_all() 函数通知等待的线程。

六、类模板

类模板其实很简单,就是 模板 应用到 类。

在C++中,类模板(Class Template)是一种用于生成具有相似结构和行为的多个类的蓝图。类模板允许您在定义类时使用类型参数,从而使得类可以适应不同的数据类型。这提高了代码的可重用性,因为您可以使用同一个类模板生成多个特定类型的类,而无需为每个类型编写单独的类定义。

以下是一个简单的类模板示例,用于创建一个通用的“Pair”类,该类可以存储两个不同类型的值:

template<typename T1, typename T2>
class Pair{
public:
    Pair(T1 first, T2 second) : first_(first),  second_(second){}

    T1 get_first() const {return first_;}
    T2 get_second() const {return second_;}

    void set_first(T1 first) {first_ = first;}
    void set_second(T2 second) {second_ = second;}

private:
    T1 first_;
    T2 second_;
}

在这个示例中,T1T2是类型参数,它们可以用任何类型替换。当我们实例化一个Pair对象时,我们需要为这两个类型参数提供具体的类型。例如:

Pair<int, std::string> pair1(42, "Hello");
Pair<double, bool> pair2(3.14, true);

类模板的成员函数(包括构造函数和析构函数)在类模板定义中通常是内联的。这意味着它们的定义直接放在类模板定义中。然而,如果需要将成员函数定义放在类模板定义之外,可以使用以下语法:

template<typename T1, typename T2>
class Pair {
public:
    Pair(T1 first, T2 second);
    
    T1 get_first() const;
    T2 get_second() const;

    void set_first(T1 first);
    void set_second(T2 second);

private:
    T1 first_;
    T2 second_;
};

// 成员函数定义
template<typename T1, typename T2>
Pair<T1, T2>::Pair(T1 first, T2 second) : first_(first),    second_(second) {}

template<typename T1, typename T2>
T1 Pair<T1, T2>::get_first() const { return first_; }

template<typename T1, typename T2>
T2 Pair<T1, T2>::get_second() const { return second_; }

template<typename T1, typename T2>
void Pair<T1, T2>::set_first(T1 first) { first_ = first; }

template<typename T1, typename T2>
void Pair<T1, T2>::set_second(T2 second) { second_ = second; }

扩展问题

1. 为什么C++中栈是最自然的内存使用方式?如果我创建一个对象要给各个业务使用,我也是使用栈吗?

在这种情况下,你可以考虑使用堆内存。堆内存可以动态分配和释放,对象的生命周期不受函数调用的限制。你可以在堆上创建一个对象,并通过指针或引用在各个业务中使用。当对象不再需要时,可以手动释放堆内存。需要注意的是,使用堆内存需要更加关注内存管理,以避免内存泄漏和资源泄漏。在C++11及更高版本中,可以使用智能指针(如std::shared_ptr和std::unique_ptr)来简化堆内存管理。