梁越

多线程

0 人看过

线程相关

什么是线程

线程是CPU调度的基本单位,在早期,单核CPU上,一个CPU在某个事件执行一个线程,这就没有多线程的说法,后来单核CPU采取时间片轮转调度,不同的线程分配一定的时间,并在时间结束后切换线程,也就是CPU频繁切换线程,让我们看起来多个任务真的在“同时”进行,其实只是单核在不停切换,到了多核CPU才实现了真正的多线程,异步进行,每个核心都可以处理一个线程

线程占有的资源

寄存器

程序计数器
状态

线程数量设置多少合适

一般一个线程占用1M的内存,理论上,一个2G的内存,可以开辟2048个线程,但是线程多也不意味着高并发,工作线程数量主要由CPU核心数和处理器能力决定,一般一个核心一个线程最佳,如果是CPU密集型,会设置线程数N+1或者N+2,N是核心数,如果是IO密集型,设置为2N。当然还有个公式

最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

线程池设计

代码可见[https://github.com/progschj/ThreadPool/blob/master/ThreadPool.h]

主要是工作线程std::vector< std::thread > workers,一个vector,每个元素都是一个循环等待任务,然后是任务队列std::queue< std::function<void()> > tasks

线程池如果核心线程满了,就加入任务队列,如果队列也满了,那这一般会有集中策略

  1. 丢弃任务,同时抛出异常
  2. 丢弃任务,不抛出异常
  3. 删除队列队头的任务,然后加入
  4. 直接运行该任务

线程调度策略

(可见)[https://www.openrad.ink/2021/08/31/%E8%BF%9B%E7%A8%8B%E8%B0%83%E5%BA%A6%E7%AD%96%E7%95%A5/]

什么是锁,为什么要锁

多线程伴随的是并发问题,在不同线程访问同一个资源的时候,会发生不一致的情况,为了数据的同步,必须使用锁

锁的种类

按照锁的种类分类,可以分为以下几种

  1. 互斥锁
  2. 自旋锁
  3. 条件变量

1. 对于互斥锁在C++标准库里有的:

  • std::mutex,可以阻塞式等锁(lock())也可以非阻塞式上锁(try_lock()),lock可以同时锁定几个互斥量,try_lock如果锁定失败会直接返回

  • std::timed_mutex,互斥锁的加时版,如果在一段时间内(try_lock_for())或是在某个时间之前(try_lock_until())获取锁成功则成功上锁

  • std::recursive_mutex,递归互斥锁,在互斥锁的基础上允许持有锁的线程多次通过lock()或者try_lock()获取锁,而std::mutex的拥有者不能继续请求上锁

  • std::recursive_timed_mutex,递归互斥锁加时版

  • std::shared_mutex,共享互斥锁,允许多个线程共享锁(lock_shared()系列),但只有一个线程能够持有互斥锁(lock()系列),也就是一般所说的读写锁

  • std::shared_timed_mutex,共享互斥锁的加时版本

2. 自旋锁:

自旋锁其实是获取锁失败时阻塞等待,比较消耗CPU时间,所以比较适合占用锁比较少时间的场景

在c++里实现自旋

链接:https://www.nowcoder.com/questionTerminal/554355eea5aa44d697a3a4bc99795207
来源:牛客网

#include <atomic>
#include <iostream>

std::atomic_flag lock = ATOMIC_FLAG_INIT; // 这是个标准库里的宏

void spin_lock_output(int n) {
    // 上锁
    while(lock.test_and_set(std::memory_order_acquire))
        ; // 忙等自旋
    std::cout << "output from thread " << n << std::endl;
    // 解锁
    lock.clear(std::memory_order_release);
}

3. 条件锁

也就是满足某个条件时才继续
std::condition_variable,需要搭配std::unique_lock来使用
std::condition_variable_any,不限于std::unique_lock

原子变量

原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中,CPU绝不会再去进行其他的针对该值的操作。为了实现这样的严谨性,原子操作仅会由一个独立的CPU指令代表和完成。原子操作是无锁的,常常直接通过CPU指令直接实现。事实上,其它同步技术的实现常常依赖于原子操作。

std::atomic,原子变量。但不保证原子性不是由锁来实现的
std::atomic_flag,原子性的标记变量,保证其原子性的实现是无锁的

上面的自旋锁就是用原子变量实现的

RAII式锁管理器

c++里有自动管理锁的管理器

  1. std::lock_guard,自动上锁,退出作用域自动解锁,但是提前解锁做不到

  2. std::unique_lock,独享所有权的锁管理器,除基础RAII功能之外还能移交所有权(此时不解锁),(解锁后)上锁和(提前)解锁

  3. std::shared_lock,配合共享锁使用的锁管理器

再深入了解读写锁

在c++里实现读写锁

#include <iostream>
//std::unique_lock
#include <mutex> 
#include <shared_mutex>
#include <thread>

class ThreadSafeCounter {
public:
    ThreadSafeCounter() = default;

    // 多个线程/读者能同时读计数器的值。
    unsigned int get() const {
        std::shared_lock<std::shared_mutex> lock(mutex_);
        return value_;
    }

    // 只有一个线程/写者能增加/写线程的值。
    void increment() {
        std::unique_lock<std::shared_mutex> lock(mutex_);
        value_++;
    }

    // 只有一个线程/写者能重置/写线程的值。
    void reset() {
        std::unique_lock<std::shared_mutex> lock(mutex_);
        value_ = 0;
    }

private:
    mutable std::shared_mutex mutex_;
    unsigned int value_ = 0;
};

int main() {
    ThreadSafeCounter counter;

    auto increment_and_print = [&counter]() {
        for (int i = 0; i < 3; i++) {
            counter.increment();
            std::cout << std::this_thread::get_id() << '\t' << counter.get() << std::endl;
        }
    };

    std::thread thread1(increment_and_print);
    std::thread thread2(increment_and_print);

    thread1.join();
    thread2.join();

    system("pause");
    return 0;
}

悲观锁和乐观锁

这是两个抽象的概念,这两种形式的锁一般用于数据库访问和更新

  1. 悲观锁是指当你访问和修改数据前,需要对数据加锁,可能是数据库中的行锁,表锁,读写锁.这样通过加锁能够很好的保证数据一致性,但是锁会带来开销

  2. 乐观锁是指当你访问数据时可以直接获取到,但当你需要更新时,你需要验证版本号或者上一次访问的时间戳,来确保没有人在你之前更新过数据,如果发现版本号和时间戳改变了,就重新获取该数值,再一次更新;如果发现版本号和时间戳没改变,则直接更新.

乐观锁在适合在多读的场景,如果在多写下,乐观锁不断失败重试反而性能降低

乐观锁虽然在业务层无锁,但是在底层更新的时候也会用到锁,只不过在底层的锁粒度更小,开销也更小 总的来说,乐观锁是先读后锁,悲观锁是先锁后读