atomic包装自定义类型

在学习原子变量之初,学过很多它的操作,但是很多都是在内置类型(int、long等)上进行的学习和实验。这次由于工作需要,要使用 atomic 来包装自定义类型,因此打算好好探究一番,把它彻底搞懂。

当要使用 atomic 包装自定义类型的时候首先肯定会产生疑问——C++是否支持这样使用?

于是乎,去 cppreference 上搜了一下(C++17版本、其他的版本也支持),发现允许使用 atomic 包装平凡拷贝(TriviallyCopyable)的自定义类型。这时候又会有一个疑惑什么是平凡拷贝?如何确定自己定义的类型是不是平凡拷贝的?
在这里插入图片描述
从图中可以看出如果你不确定你自定义的结构是否是 TriviallyCopyable 的,你可以拿上面五个值打印一下,当全部为 true 时,那么恭喜你,你定义的结构体是 TriviallyCopyable 的,可以使用 atomic 来包装它。至于具体的平凡拷贝的定义可以去 cppreference 上查一下,此处不再展开(下文会简单提到如何看一个类型是不是 TriviallyCopyable 的)。

场景分析

目前程序中存在两种线程即: mertrics_thread (下文称为 M 线程,其作用是将全局变量中的值进行上报并在上报之后进行复位,数量为 1) 、worker_thread(下文称为 W 线程,其作用是采集某些工作函数运行耗时和次数并将其更新到全局变量中,数量为 3),还存在一个全局变量(用于记录采集到的值)。首先定义全局变量类型 Counters

struct Counters{
    uint64_t a;
    int b;
};

如果我们不对其加以修饰就直接实例化它,并使用起来,那么必然会造成数据竞争。因此我们需要用 atomic 包装它,即定义一个全局变量 counter_atomic

std::atomic<Counters> counter_atomic;

前文提到 atomic 包装的变量必须是 TriviallyCopyable 的,我们可以拿上面的五个 value 来看一下我们定义的结构是否满足

int main() {

    std::cout << std::is_trivially_copyable<Counters>::value << std::endl;
    std::cout << std::is_copy_constructible<Counters>::value << std::endl;
    std::cout << std::is_move_constructible<Counters>::value << std::endl;
    std::cout << std::is_copy_assignable<Counters>::value << std::endl;
    std::cout << std::is_move_assignable<Counters>::value << std::endl;

    return 0;
}

在这里插入图片描述

从输出可以发现,其是 TriviallyCopyable 的,那么如何保证我们定义的结构是 TriviallyCopyable 的呢?在我看来只要其内部的值全是内置类型,然后不要去自己捣鼓构造函数、拷贝和移动相关的函数就行了。(这里就是前面提到的下文啦!!!)

实现

前置知识

实现必然涉及到原子变量的 CAS 操作,exchange()compare_exchange_strong()compare_exchange_weak() 这几个函数。

首先来看 exchange, 其函数签名是

_Tp exchange(_Tp __d, memory_order __m = memory_order_seq_cst)

其使用场景是将原子变量包装的值更新为 __d, 并返回原子变量之前保存的值。这个函数正好满足 M 线程中需要进行的 “读取——复位” 的操作。

然后来看两个 “比较——交换” 函数,它们的函数签名是

bool compare_exchange_strong(_Tp& __e, _Tp __d, memory_order __m = memory_order_seq_cst)

bool compare_exchange_weak(_Tp& __e, _Tp __d, memory_order __m = memory_order_seq_cst)

其使用场景是:有一个期望值 __e,将其与原子变量中存储的值进行比较,当相等时,就把 __d 存储到原子变量中,并返回 true;当不相等时,就把原子变量中存储的值赋给 __e,并返回 false

_strong_weak 的区别在于后者可能在某些平台上存在 Spurious Failure 问题,即 _weak 版本可能会在 __e 的值和原子变量中存储的值相等时,返回 false(仅在某些平台上会这样,X86 上面不会这样子)。_strong 在遇到 Spurious Failure 问题时会进行重试,即其在内部存在一个循环来弥补 _weak 的不足。本质原因还是在某些平台上面使用一系列指令来实现 CAS 原语,而在 x86 这样的机器上面,直接使用一条指令来实现 CAS 原语。

#include <iostream>
#include <atomic>
#include <chrono>
#include <thread>


struct Counters{
    uint64_t a = 0;
    int b = 0;
};

std::atomic<Counters> counters_atomic;

void metrics_report(){

    while(true) {
        // 取出 counters_atomic 中的值,并进行复位
        Counters new_counters;
        Counters old_counters = counters_atomic.exchange(new_counters);

        // 模拟指标上报,将采集到的 old_counters 上报上去。
        // 每间隔 3s 上报一次采集到的信息
        std::cout << " a: " << old_counters.a << " b: " << old_counters.b << std::endl;
        std::this_thread::sleep_for(std::chrono::seconds(3));
    }
}

void worker(){

    while(true){
        uint64_t start_time = 1000000;

        // 模拟工作线程的耗时操作
        std::this_thread::sleep_for(std::chrono::milliseconds (500));

        uint64_t end_time = 3000000;
        uint64_t diff_time = end_time - start_time;

        Counters old_val = counters_atomic.load();
        Counters new_val;

        do{
            new_val = old_val;
            new_val.a += diff_time;
            new_val.b += 2;
        }while(!counters_atomic.compare_exchange_strong(old_val, new_val));

    }
}

int main() {

    std::thread worker_a(worker);
    std::thread worker_b(worker);
    std::thread worker_c(worker);

    std::thread metrics_thread(metrics_report);

    worker_a.join();
    worker_b.join();
    worker_c.join();
    metrics_thread.join();

    return 0;
}

参考

  1. std::atomic | compare_exchange_weak vs. compare_exchange_strong [duplicate]
  2. cppreference.com
  3. Understand std::atomic::compare_exchange_weak() in C++11
<think>好的,我现在需要解决的问题是如何基于现有的无锁队列FreeLockQueue实现一个支持工作窃取(work stealing)机制的线程池,并且要尽量支持接受std::function类型的任务。根据用户提供的FreeLockQueue实现,它的内部使用了一个std::atomic<T>*数组,而用户之前提到过因为std::function不是trivially copyable,导致无法直接存储。所以首先需要解决这个问题。 首先,回顾之前用户的问题,原来的线程池因为队列元素是std::atomic<T>,而T是std::function时无法编译。之前的解决方案是改用锁保护的队列,但现在用户希望用无锁队列FreeLockQueue来实现,并且支持work stealing。这意味着需要找到一种方法让FreeLockQueue能够处理std::function类型的任务,同时避免之前的问题。 根据FreeLockQueue的实现代码,push和pop操作都是基于模板类型T的,而内部存储是std::atomic<T>* array_。由于std::atomic要求T是TriviallyCopyable,而std::function不满足这一点,所以直接存储std::function<T>到FreeLockQueue中会导致编译错误。因此,必须寻找一种间接存储的方法,例如存储指向任务的指针或者包装器,而这些包装器本身是TriviallyCopyable的。 可能的思路是将任务包装成一个指针类型,比如std::shared_ptr或者裸指针,因为这些指针类型是TriviallyCopyable的。这样,FreeLockQueue的T可以是这样的指针类型,从而绕过std::atomic的限制。例如,将任务封装为std::shared_ptr<std::function<void()>>,然后将这个智能指针作为队列的元素类型。因为std::shared_ptr的控制块本身可能不是TriviallyCopyable,但指针本身(即指向控制块的指针)是trivially copyable的。不过需要注意,std::shared_ptr可能不满足TriviallyCopyable的要求,因此可能需要使用裸指针或者自定义的包装结构。 另一个可能性是使用类型擦除技术,将任务存储为void*,并附带执行和清理函数。例如,定义一个结构体,包含一个执行函数指针和一个数据指针,这样队列存储的是这个结构体实例,而该结构体是TriviallyCopyable的。这种方法类似于之前用户提到的TaskWrapper结构,其中包含execute、data和deleter函数指针。这样,每次从队列中取出结构体后,调用execute函数指针传入data,执行任务,之后调用deleter释放资源。 但需要确认这样的结构体是否满足TriviallyCopyable的要求。在C++中,如果结构体的所有成员都是TriviallyCopyable的,并且没有用户定义的构造函数、析构函数或拷贝控制成员,则结构体本身也是TriviallyCopyable的。函数指针和void*都是基本类型,因此这样的结构体应该是TriviallyCopyable的,可以安全地用于std::atomic。 因此,可能的解决方案是: 1. 定义一个TriviallyCopyable的任务包装器结构体,如: struct TaskWrapper { void (*execute)(void*); void* data; void (*deleter)(void*); }; 这个结构体的每个成员都是函数指针或void*,都是TriviallyCopyable的,所以整个结构体也是TriviallyCopyable的,可以作为FreeLockQueue的模板参数T。 2. 在将std::function任务推入队列时,将其封装为TaskWrapper。例如,使用一个模板函数来创建TaskWrapper,将std::function包装到堆上,并设置execute和deleter函数。 3. 当队列取出TaskWrapper时,调用execute函数指针,传入data执行任务,然后调用deleter释放内存。 接下来,考虑如何将这个任务包装器与FreeLockQueue结合,实现线程池的工作窃取机制。工作窃取通常涉及每个线程有一个本地任务队列,当本地队列为空时,从其他线程的队列中窃取任务。这里用户提供的FreeLockQueue可能作为每个线程的任务队列,因此需要为每个线程维护一个FreeLockQueue<TaskWrapper>实例。 但是,FreeLockQueue的实现可能仅支持单生产者单消费者(SPSC)或多生产者多消费者(MPMC)模式,需要根据实际情况调整。假设FreeLockQueue是线程安全的,可以在多线程环境下使用。 然后,线程池的接口设计需要能够接受std::function类型的任务。用户可能希望像之前使用锁的队列那样,通过enqueue函数传递std::function和参数。但由于FreeLockQueue只能存储TaskWrapper,所以需要将std::function转换为TaskWrapper。 具体步骤可能如下: - 当用户调用enqueue提交任务时,将任务(std::function)包装到堆上,生成TaskWrapper,其中execute函数会调用std::function,deleter负责delete该对象。 - 将TaskWrapper推入某个线程的任务队列(可能是主队列或某个工作线程的队列)。 - 工作线程从自己的队列中pop任务,如果为空,则尝试从其他线程的队列中窃取任务。 - 执行任务时,调用TaskWrapper的execute函数,传入data指针,执行实际的std::function。 需要考虑的问题包括: 1. 如何管理TaskWrapper中data指针的生命周期,避免内存泄漏。例如,当任务被推入队列后,确保在任务执行后正确释放资源。使用deleter函数指针来调用对应的释放函数。 2. 线程池中的每个线程是否拥有自己的FreeLockQueue实例,或者存在一个全局的任务队列。工作窃取通常要求每个线程有自己的队列,这样当自己的队列为空时,可以窃取其他队列的任务。因此,可能需要为每个工作线程维护一个FreeLockQueue<TaskWrapper>。 3. 如何实现任务窃取。当线程A的任务队列为空时,它需要遍历其他线程的队列,尝试从其他队列中窃取任务。这需要访问其他线程的队列,可能需要使用某种线程间的通信机制,或者让每个线程的队列对其他线程可见。 4. 如何确保线程安全。因为FreeLockQueue本身是无锁的,但窃取任务可能涉及多个线程同时访问不同的队列,需要确保队列的访问是线程安全的。例如,窃取操作可能需要从其他队列的尾部或头部获取任务,这取决于队列的实现方式(如双端队列)。 可能的实现步骤: - 线程池类维护一组工作线程,每个工作线程有一个FreeLockQueue<TaskWrapper>队列。 - enqueue函数将任务包装成TaskWrapper,并将任务推入某个队列(如轮询选择线程队列,或者主队列)。 - 每个工作线程循环执行以下操作: a. 尝试从自己的队列中pop任务。 b. 如果成功,执行任务。 c. 如果失败,随机选择其他线程的队列,尝试窃取任务。 d. 如果窃取成功,执行任务。 e. 否则,可能进入休眠或继续尝试。 需要解决的具体问题: - 如何将std::function转换为TaskWrapper。例如,使用模板函数来生成TaskWrapper,捕获std::function及其参数,并将其存储到堆上,然后用execute函数调用它。 例如,可以写一个辅助函数: template<typename Func> TaskWrapper make_task(Func&& func) { using FuncType = std::decay_t<Func>; FuncType* f = new FuncType(std::forward<Func>(func)); return TaskWrapper{ [](void* ptr) { (*static_cast<FuncType*>(ptr))(); }, f, [](void* ptr) { delete static_cast<FuncType*>(ptr); } }; } 这样,当用户提交一个std::function时,可以将其传递给make_task,生成对应的TaskWrapper,然后推入队列。 但是,std::function本身可能携带状态,比如捕获的lambda,需要确保在堆上正确构造和析构。这里用new创建FuncType的副本,并在deleter中delete它,确保生命周期正确。 接下来,线程池的enqueue函数可能需要将用户提供的函数和参数绑定为一个std::function<void()>,然后用make_task包装成TaskWrapper,推入队列。 例如: template<typename Func, typename... Args> void enqueue(Func&& func, Args&&... args) { auto bound_func = std::bind(std::forward<Func>(func), std::forward<Args>(args)...); auto task = make_task(std::move(bound_func)); // 选择一个队列(比如轮询)并push queue_.push(std::move(task)); } 不过,std::bind返回的对象类型可能不是std::function,需要转换为void()的可调用对象。或者,使用lambda来包装: template<typename Func, typename... Args> void enqueue(Func&& func, Args&&... args) { auto task_func = [func = std::forward<Func>(func), args...]() mutable { func(args...); }; auto task = make_task(std::move(task_func)); // 推入队列 } 不过,这里可能存在参数传递的问题,比如完美转发。更正确的方法是使用lambda捕获参数的完美转发,但lambda的捕获列表不支持直接完美转发参数包。因此,可以使用std::tuple来保存参数,并在执行时展开。 例如,使用std::apply: template<typename Func, typename... Args> void enqueue(Func&& func, Args&&... args) { auto task_func = [func = std::forward<Func>(func), args = std::make_tuple(std::forward<Args>(args)...)]() mutable { std::apply(func, std::move(args)); }; auto task = make_task(std::move(task_func)); // 推入队列 } 这样,可以正确地将参数转发给func。 然后,将这个task_func封装到TaskWrapper中,并推入队列。 现在,关于线程池的具体实现,每个工作线程需要有一个FreeLockQueue<TaskWrapper>队列。当线程执行任务时,首先从自己的队列中取出任务,如果失败,则尝试窃取其他线程的任务。 实现工作窃取时,可能需要让线程池管理所有线程的队列,并提供访问这些队列的方法。例如,线程池可以有一个成员变量std::vector<FreeLockQueue<TaskWrapper>> queues_;,每个工作线程对应一个队列。当线程需要窃取任务时,随机选择一个其他线程的队列,并尝试从中pop任务。 但FreeLockQueue的pop操作可能返回一个值,需要考虑队列是否为空。如果pop在队列为空时可能阻塞或返回空值,需要根据FreeLockQueue的具体实现来处理。假设FreeLockQueue的pop在队列为空时返回一个默认构造的T,或者有某种方式检查队列是否为空,那么工作线程可以循环尝试从其他队列中窃取任务。 例如,工作线程的主循环: void worker_thread(int thread_index) { while (!stopped) { TaskWrapper task; // 先尝试从自己的队列中取任务 if (queues_[thread_index].try_pop(task)) { task.execute(task.data); task.deleter(task.data); continue; } // 否则,尝试窃取其他队列的任务 bool stolen = false; for (int i = 0; i < queues_.size(); ++i) { if (i == thread_index) continue; if (queues_[i].try_pop(task)) { stolen = true; break; } } if (stolen) { task.execute(task.data); task.deleter(task.data); } else { // 休眠或yield std::this_thread::yield(); } } } 这里假设FreeLockQueue有一个try_pop方法,非阻塞地尝试弹出元素。如果原FreeLockQueue的pop是阻塞的,那么可能需要修改其实现,或者使用超时等方式。 另外,关于队列的选择,窃取时可能需要从其他队列的尾部或头部取任务,这取决于FreeLockQueue的实现是否是双端队列。例如,工作窃取通常从其他队列的尾部窃取任务,而本地线程从头部取任务,以减少竞争。如果FreeLockQueue是单端队列(如FIFO),可能无法直接支持这种操作。因此,可能需要将FreeLockQueue设计为支持双端操作,或者改用其他结构。 但根据用户提供的FreeLockQueue实现,它有一个push和pop方法,可能仅支持单端操作。因此,在这种情况下,工作窃取可能只能以同样的方式从队列中取任务,这可能导致竞争,但如果是无锁实现,可能可以接受。 另外,用户提供的FreeLockQueue的push和pop操作的具体实现可能影响线程窃取的实现。例如,push可能将元素添加到数组的某个位置,而pop从另一个位置取出。需要确保在多线程环境下,多个线程同时进行push和pop操作的正确性。 假设FreeLockQueue的实现是线程安全的,支持多生产者和多消费者,那么多个线程同时尝试窃取同一个队列的任务时,不会导致数据竞争。因此,每个工作线程可以安全地从其他队列中窃取任务。 总结步骤: 1. 定义TaskWrapper结构,确保其是TriviallyCopyable,可以存储在FreeLockQueue中。 2. 实现辅助函数将std::function转换为TaskWrapper。 3. 线程池维护一组FreeLockQueue<TaskWrapper>,每个工作线程一个队列。 4. enqueue函数将任务封装为TaskWrapper,并推入某个队列(例如轮询或主队列)。 5. 工作线程循环从自己的队列中取任务,失败时窃取其他队列的任务。 6. 执行任务时调用TaskWrapper的execute和deleter。 潜在的问题: - FreeLockQueue的pop实现是否正确处理了并发情况,尤其是当多个线程同时尝试窃取同一个队列时。需要确保无锁队列的实现是正确的MPMC队列。 - TaskWrapper的data指针的管理是否正确,避免内存泄漏或悬垂指针。确保每个TaskWrapper在push到队列后,无论是否被执行,都会被正确释放。 - 性能问题:使用无锁队列可能比基于锁的队列性能更好,但工作窃取时可能需要频繁访问其他线程的队列,导致缓存一致性开销。需要合理设计队列的数量和窃取策略。 可能的优化: - 每个工作线程优先处理本地队列的任务,减少跨线程通信。 - 使用任务批处理,减少队列操作的频率。 - 在任务窃取时,采用随机选择或其他策略减少竞争。 综上,基于用户提供的FreeLockQueue,通过任务包装和正确管理生命周期,可以实现支持工作窃取的线程池,并接受std::function类型的任务。</think>--- ### **解决方案** 基于`FreeLockQueue`实现支持**工作窃取(Work Stealing)**的线程池,需解决两个核心问题: 1. **存储非平凡类型任务**:通过间接存储(指针/包装器)兼容`std::atomic`的平凡拷贝要求。 2. **任务窃取逻辑**:允许线程从其他线程的队列中“窃取”任务。 以下是具体实现步骤: --- ### **1. 定义任务包装器** 设计`TriviallyCopyable`的包装器`TaskWrapper`,存储任务执行逻辑和清理函数: ```cpp struct TaskWrapper { void (*execute)(void*); // 函数指针,用于执行任务 void* data; // 指向实际任务数据的指针 void (*deleter)(void*); // 清理资源的函数指针 // 默认构造函数(确保类型为TriviallyCopyable) TaskWrapper() : execute(nullptr), data(nullptr), deleter(nullptr) {} }; ``` --- ### **2. 任务包装函数** 将任意`std::function<void()>`封装为`TaskWrapper`: ```cpp template <typename Func> TaskWrapper make_task(Func&& func) { // 在堆上分配内存存储任务,确保生命周期可控 using FuncType = std::decay_t<Func>; FuncType* func_ptr = new FuncType(std::forward<Func>(func)); return TaskWrapper{ [](void* ptr) { (*static_cast<FuncType*>(ptr))(); }, // 执行任务 func_ptr, // 数据指针 [](void* ptr) { delete static_cast<FuncType*>(ptr); } // 清理函数 }; } ``` --- ### **3. 线程池类设计** 每个线程拥有独立的`FreeLockQueue<TaskWrapper>`,支持任务提交和窃取: ```cpp #include <vector> #include <thread> #include <functional> class WorkStealingThreadPool { public: WorkStealingThreadPool(size_t thread_num = std::thread::hardware_concurrency()); ~WorkStealingThreadPool(); template <typename Func, typename... Args> void enqueue(Func&& func, Args&&... args); private: std::vector<FreeLockQueue<TaskWrapper>> queues_; // 每个线程一个队列 std::vector<std::thread> threads_; std::atomic<bool> stop_{false}; void worker_thread(int thread_index); bool try_steal_task(TaskWrapper& task, int thief_index); }; ``` --- ### **4. 工作线程逻辑** 线程优先处理本地队列,失败时窃取其他线程的任务: ```cpp void WorkStealingThreadPool::worker_thread(int thread_index) { while (!stop_) { TaskWrapper task; // 1. 尝试从本地队列获取任务 if (queues_[thread_index].pop(task)) { task.execute(task.data); task.deleter(task.data); continue; } // 2. 尝试从其他队列窃取任务 if (try_steal_task(task, thread_index)) { task.execute(task.data); task.deleter(task.data); } else { std::this_thread::yield(); // 避免忙等待 } } } bool WorkStealingThreadPool::try_steal_task(TaskWrapper& task, int thief_index) { for (size_t i = 0; i < queues_.size(); ++i) { if (i == thief_index) continue; if (queues_[i].pop(task)) { return true; } } return false; } ``` --- ### **5. 任务提交接口** 绑定函数和参数,生成任务并推入队列(示例轮询选择队列): ```cpp template <typename Func, typename... Args> void WorkStealingThreadPool::enqueue(Func&& func, Args&&... args) { // 将函数和参数绑定为void()类型 auto bound_task = [func = std::forward<Func>(func), args = std::make_tuple(std::forward<Args>(args)...)]() { std::apply(func, args); }; // 包装为TaskWrapper TaskWrapper task = make_task(std::move(bound_task)); // 轮询选择一个队列(可优化为随机选择) static size_t index = 0; queues_[index % queues_.size()].push(std::move(task)); index++; } ``` --- ### **6. 构造函数与析构** 初始化线程和队列,安全终止所有线程: ```cpp WorkStealingThreadPool::WorkStealingThreadPool(size_t thread_num) { queues_.resize(thread_num); for (size_t i = 0; i < thread_num; ++i) { threads_.emplace_back([this, i] { worker_thread(i); }); } } WorkStealingThreadPool::~WorkStealingThreadPool() { stop_ = true; for (auto& thread : threads_) { if (thread.joinable()) thread.join(); } } ``` --- ### **关键设计点** 1. **间接存储**:通过`TaskWrapper`包装任务,确保队列元素类型为`TriviallyCopyable`。 2. **无锁队列兼容性**:`FreeLockQueue`存储`TaskWrapper`,避免直接操作`std::function`。 3. **工作窃取策略**:线程优先处理本地任务,减少竞争;窃取时遍历其他队列。 4. **生命周期管理**:使用`deleter`函数确保任务数据正确释放。 --- ### **使用示例** ```cpp int main() { WorkStealingThreadPool pool(4); // 提交任务 for (int i = 0; i < 10; ++i) { pool.enqueue([i] { std::cout << "Task " << i << " executed by thread " << std::this_thread::get_id() << std::endl; }); } std::this_thread::sleep_for(std::chrono::seconds(1)); return 0; } ``` --- ### **性能优化建议** 1. **任务窃取策略**:随机选择目标队列,避免集中竞争。 2. **批处理任务**:减少队列操作频率。 3. **缓存友好性**:每个线程的队列内存布局尽量紧凑。 4. **动态负载均衡**:根据队列负载调整窃取频率。 通过上述设计,可基于`FreeLockQueue`实现高效的工作窃取线程池,同时兼容`std::function`任务类型。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值