一文讲完多线程多进程计算
变量
static静态变量
- 多线程中的静态变量:
- 共享状态:静态变量是共享的,多个线程可以同时读取和写入它,可能导致数据竞争
- 线程安全:访问静态变量时,需要使用同步机制(如锁)来确保线程安全。
- 初始化:静态变量的初始化可能会发生多次初始化的问题
- 线程安全都是由全局变量和静态变量引起的。
- 若每个线程中对全局变量、静态变量只有读操作,⽽无写操作,⼀般来说,这个全局变量是线程安全的;
- 若有多个线程同时执行写操作,一般都需要考虑线程同步,否则的话就可能影响线程安全。
volatile关键字
- 阻止了编译器对该变量进行优化,确保每次访问该变量时都会从内存中重新读取其值
- volatile 仅保证变量的可见性(即,一个线程对volatile变量的修改对其他线程是可见的),但并不保证操作的原子性或内存操作的顺序。
- 在多线程编程中,应使用适当的同步机制(如互斥锁、信号量、条件变量等)来保护共享数据的访问,而不仅仅是依赖volatile。
线程 Thread
线程与进程
- 一个进程中可以并发多个线程,每条线程并行执行不同的任务。
- 因为虚拟内存的关系不同的进程互相不知道对方的存在,相对独立,互相有独立的内存空间,地址空间;
- 相同进程内的线程之间共享进程的内存空间,文件描述符表,部分寄存器等资源,但是每个线程都有自己独立的线程栈和部分寄存器,线程间访问资源需要考虑互斥问题。
- 进程创建,销毁,切换代价大;
- 线程创建,销毁,切换小。
- 进程有利于资源管理和保护,开销大;
- 线程不利于资源管理和保护,需要使用锁保证线程的安全运行,开销小
- 相同进程中的不同线程因为资源共享不存在通信问题,主要是资源的互斥访问,一般需要资源加锁以防死锁。
- PyTorch的DataLoader加载数据是多线程还是多进程的?
- num_worker指定进程数,大于 0 则为多进程
- 大多数情况下,是多进程的,多进程不共享内存,适合数据预处理复杂且内存占用较大的情况
- 部分情况下,可以是多线程的,多线程共享内存,适合数据处理简单的情况
- 遇到问题是,可以减少num_worker的值,方便调试;或使用torch.utils.bottleneck分析性能瓶颈
- 如何查看进程?
- linux 使用 ps 、 top 命令
线程切换时的栈操作?
- 保存当前线程的上下文。
- 保存寄存器状态压入其内核栈(每个线程通常有一个用户栈和内核栈)。
- 保存栈指针(ESP):将当前栈指针(指向线程的内核栈顶部)保存到该线程的**线程控制块(TCB)**中。
- 切换内核栈:调度器切换到新线程的内核栈(通过修改CPU的ESP寄存器指向新线程的内核栈)。
- 恢复新线程的上下文
- 加载新线程的栈指针
- 弹出寄存器状态
- 切换用户栈(若需要):CPU会通过TSS(任务状态段)或特定指令自动切换回用户栈。
协程
- 协程 Coroutine,比线程更加轻量级,允许多个程序段在同一个线程内并发
- 协程的调度依赖于程序内部的逻辑
- 适合异步代码,切换开销小
- C++在c++20中引入了协程,C可以通过 setjmp、longjmp实现,libco、lictask也提供了支持
- 协程的数量如何限制
- 使用线程池
- 使用信号量控制资源的访问
- 使用通道限制通信的同步
线程特性
- 何为原子性:代码实现时不会受到其它线程的穿插执行,这样就保证了这段代码的原子性了。有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
- 永远不要在多线程程序里面调用fork
- Linux的fork函数, 会复制一个进程, 对于多线程程序而言, fork函数复制的是用fork的那个线程, 而并不复制其他的线程。 fork之后其他线程都不见了。
- Linux存在forkall语义的系统调用, 无法做到将多线程全部复制。
- 多线程程序在fork之前, 其他线程可能正持有互斥量处理临界区的代码。 fork之后, 其他线程都不见了, 那么互斥量的值可能处于不可用的状态, 也不会有其他线程来将互斥量解锁。
- 如何解决线程不安全问题
- 如果多个线程同时要求执行临界区的代码, 并且当前临界区并没有线程在执行, 那么只能允许一个线程进入该临界区。
- 线程的 run()和 start()有什么区别?
- start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start()只能调用一次。
- new 一个线程Thread,线程进入新建状态;调用start(),会启动一个线程并使线程进入就绪状态,当分配到时间片就可以开始工作了,start()会执行线程的相应准备工作,然后自动执行run()方法的内容,这是真正的多线程工作。
- 而直接执行 run() 相当于把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。
线程状态
- 线程5个状态:创建、就绪、运行、阻塞和死亡。
线程池
- 创建一个全新的OS线程需要内存分配和CPU指令,以便对其进行设置和销毁。为了更好地处理线程的使用并避免创建新线程,操作系统或平台考虑了一项 Thread Pool(线程池)功能,该功能使应用程序可以使用已经存在的线程。这是处理多个线程而不处理其创建或销毁的更有效的方法。
- 线程池采用预创建的技术,在应用程序启动之后,将立即创建一定数量的线程(N1),放入空闲队列中。这些线程都是处于阻塞(Suspended)状态,不消耗CPU,但占用较小的内存空间。
- 当任务到来后,缓冲池选择一个空闲线程,把任务传入此线程中运行。当N1个线程都在处理任务后,缓冲池自动创建一定数量的新线程,用于处理更多的任务。
- 在任务执行完毕后线程也不退出,而是继续保持在池中等待下一次的任务。
- 当系统比较空闲时,大部分线程都一直处于暂停状态,线程池自动销毁一部分线程,回收系统资源。
- 线程池的主要组成部分有:
- 任务队列(Task Queue)
- 线程池(Thread Pool)
- 线程池通常适合下面的几个场合:
- 单位时间内处理任务频繁而且任务处理时间短
- 对实时性要求较高。如果接受到任务后在创建线程,可能满足不了实时要求,因此必须采用线程池进行预创建。
- 一个分享线程池代码,代码非常的简洁,只有一个头文件ThreadPool.h,这里贴出来作为备份。
#ifndef THREAD_POOL_H
#define THREAD_POOL_H
#include <vector>
#include <queue>
#include <memory>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <future>
#include <functional>
#include <stdexcept>
class ThreadPool {
public:
ThreadPool(size_t);
template<class F, class... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>;
~ThreadPool();
private:
// need to keep track of threads so we can join them
std::vector< std::thread > workers;
// the task queue
std::queue< std::function<void()> > tasks;
// synchronization
std::mutex queue_mutex;
std::condition_variable condition;
bool stop;
};
// the constructor just launches some amount of workers
inline ThreadPool::ThreadPool(size_t threads)
: stop(false)
{
for(size_t i = 0;i<threads;++i)
workers.emplace_back(
[this]
{
for(;;)
{
std::function<void()> task;
{
// 锁定互斥锁以确保没有其他人正在访问该资源
std::unique_lock<std::mutex> lock(this->queue_mutex);
this->condition.wait(lock,
[this]{ return this->stop || !this->tasks.empty(); });
if(this->stop && this->tasks.empty())
return;
task = std::move(this->tasks.front());
this->tasks.pop();
}
task();
}
}
);
}
// add new work item to the pool
template<class F, class... Args>
auto ThreadPool::enqueue(F&& f, Args&&... args)
-> std::future<typename std::result_of<F(Args...)>::type>
{
using return_type = typename std::result_of<F(Args...)>::type;
// 创建一个执行
auto task = std::make_shared< std::packaged_task<return_type()> >(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> res = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex);
// don't allow enqueueing after stopping the pool
if(stop)
throw std::runtime_error("enqueue on stopped ThreadPool");
tasks.emplace([task](){ (*task)(); });
}
condition.notify_one();
return res;
}
// the destructor joins all threads
inline ThreadPool::~ThreadPool()
{
{
std::unique_lock<std::mutex> lock(queue_mutex);
stop = true;
}
condition.notify_all();
for(std::thread &worker: workers)
worker.join();
}
#endif
#include <iostream>
#include "ThreadPool.h"
void func()
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
std::cout<<"worker thread ID:"<<std::this_thread::get_id()<<std::endl;
}
int main()
{
ThreadPool pool(4);
while(1)
{
pool.enqueue(fun);
}
}
上下文切换
- 多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。
- 当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
- 概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换。
- 上下文切换通常是计算密集型的,对系统来说意味着消耗大量的 CPU时间,事实上,可能是操作系统中时间消耗最大的操作。
- Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。
锁和原子操作
- 锁的底层原理是什么?
- 锁的底层是通过CAS,atomic 机制实现。
- CAS机制:全称为Compare And Swap(比较相同再交换)可以将比较和交换操作转换为原子操作,CAS操作依赖于三个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧的预估值X等于内存中的值V,就将新的值B保存在内存之中。(就是每一个线程从主内存复制一个变量副本后,进行操作,然后对其进行修改,修改完后,再刷新回主内存前。再取一次主内存的值,看拿到的主内存的新值与当初保存的快照值,是否一样,如果不一样,说明有其他线程修改,本次修改放弃,重试。)
- 原子操作是什么?
- 原子操作是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何切换到另一个线程。
- 原理是:在X86的平台下,CPU提供了在指令执行期间对总线加锁的手段,CPU中有一根引线#HLOCK pin连接到北桥,如果汇编语言的程序在程序中的一条指令前面加上了前缀“LOCK”,经过汇编之后的机器码就使CPU在执行这条指令的时候把#HLOCKpin的电平拉低持续到这条指令结束的时候放开,从而把总线锁住,这样别的CPU就暂时不能够通过总线访问内存了,保证了多处理器环境中的原子性。
锁
参考这个较为详细,这里
死锁
- 死锁:当两个以上的运算单元,双方都在等待对方停止运行,以获取系统资源,但是没有一方提前退出时,就称为死锁。
- 死锁的基本条件:
- 禁止抢占(no preemption):系统资源不能被强制从一个进程中退出(我加了锁,只有我能解)
- 持有和等待(hold and wait):一个进程可以在等待时持有系统资源(加了A锁请求B锁,请求不到B锁 ,A锁不释放)
- 互斥(mutual exclusion):资源只能同时分配给一个进程或者线程,无法多个进程或者线程共享(我接了锁,别人就不能加锁)
- 循环等待(circular waiting):一系列进程互相持有其他进程所需要的资源(线程1拿了A锁请求B锁,线程2拿了B锁请求A锁)
- 死锁的四个条件缺一不可,一般如果需要解决死锁,破坏其中一个即可。
- 加锁顺序一致(按照先后顺序申请互斥锁)
- 避免未释放锁的情况,等待超时则释放锁
- 资源一次性分配
- 避免死锁的另一种方式是尝试一下,如果取不到锁就返回(不行就算了)
- 如何定位和排查死锁问题呢?查看这个文章 Linux下排除死锁
- 可以通过gdb 进入查看
thread apply all bt
堆栈,Thread1与Thread2最后都停在了 lock_wait这里,也就是说,两个线程都在等待获取一把锁,陷入死锁
- 可以通过gdb 进入查看
读写锁
- 读线程可多个同时读,而写线程只允许同一时间内一个线程去写。
- 本质上,读写锁的内部维护了一个引用计数,每当线程以读方式获取读写锁时,该引用计数+1;
- 当释放以读加锁的方式的读写锁时,会先对引用计数进行-1,直到引用计数的值为0的时候,才真正释放了这把读写锁。
- 写锁用的是独占模式,如果当前读写锁被某写线程占用着,读锁请求和写锁请求都要陷入阻塞,直到线程释放写锁。
#include <iostream>
#include <shared_mutex>
#include <thread>
#include <vector>
class ThreadSafeData {
private:
mutable std::shared_mutex mtx; // 读写锁
int data = 0; // 共享数据
public:
// 写操作 - 独占锁
void write(int value) {
std::unique_lock lock(mtx); // 获取独占锁
data = value;
std::cout << "Write: " << value << std::endl;
}
// 读操作 - 共享锁
int read() const {
std::shared_lock lock(mtx); // 获取共享锁
std::cout << "Read: " << data << std::endl;
return data;
}
};
int main() {
ThreadSafeData tsd;
// 创建多个读线程和写线程
std::vector<std::thread> threads;
// 2个写线程
for (int i = 0; i < 2; ++i) {
threads.emplace_back([&tsd, i]() {
tsd.write(i + 10);
});
}
// 5个读线程
for (int i = 0; i < 5; ++i) {
threads.emplace_back([&tsd]() {
tsd.read();
});
}
// 等待所有线程完成
for (auto& t : threads) {
t.join();
}
return 0;
}
互斥锁
- 互斥锁的本质
- 在互斥锁内部有一个计数器,其实就是互斥量,计数器的值只能为0或者为1
- 当线程获取互斥锁的时候,如果计数器当前值为0,表示当前线程不能获取到互斥锁,也就是没有获取到互斥锁,就不要去访问临界资源
- 当前线程获取互斥锁的时候,如果计数器当前值为1,表示当前线程可以获取到互斥锁,也就是意味着可以访问临界资源
- 互斥锁公平嘛?
- 互斥锁是不公平的。
- 内核维护等待队列, 互斥量实现了大体上的公平; 由于等待线程被唤醒后, 并不自动持有互斥量, 需要和刚进入临界区的线程竞争(抢锁), 所以互斥量并没有做到先来先服务。
自旋锁
- 自旋锁,spinlock,是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
- 获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting
- 和互斥锁有什么区别?
- 为保护共享资源而提出一种锁机制。
- 自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,在任何时刻最多只能有一个执行单元获得锁。
- 但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。
通信方式
- 线程通信(共享内存模型):
- 天然共享进程的堆、全局变量等内存空间。
- 通过读写共享变量直接通信(需同步机制)。
- 典型方式:互斥锁、条件变量、原子操作、线程安全队列。
- 进程通信(隔离内存模型):
- 各自拥有独立地址空间,无法直接访问对方内存。
- 必须通过操作系统提供的IPC机制。
- 典型方式:管道、消息队列、共享内存、套接字、信号。
- 原子操作:
- 原子操作是不可分割的,使用原子操作可以确保在多线程环境中操作是安全的
- 条件变量:
- 协调线程之间的协作,用来在线程之间传递信号,从而控制线程的执行流程
- 互斥量:
- 防止多个线程来同时访问共享资源,从而避免数据竞争的问题
- 管道:
- 管道分为匿名管道和命名管道,管道本质上是一个内核中的一个缓存,当进程创建管道后会返回两个文件描述符,一个写入端一个输出端
- 缺点:半双工通信,一个管道只能一个进程写,一个进程读。不适合进程间频繁的交换数据
- 消息队列:
- 可以边发边收,但是每个消息体都有最大长度限制,队列所包含的消息体的总数量也有上限并且在通信过程中存在用户态和内核态之间的数据拷贝问题
- 共享内存:
- 解决了消息队列存在的内核态和用户态之间的数据拷贝问题。
- 信号量:
- 本质上是一个计数器,当使用共享内存的通信方式时,如果有多个进程同时往共享内存中写入数据,有可能先写的进程的内容被其他进程覆盖了,信号量就用于实现进程间的互斥和同步PV操作不限于信号量±1,而且可以任意加减正整数
- 信号量作为通信使用时,进程/线程对信号量+1或者-1,很有可能只是通知对方某件事情已经完成,并没有锁的概念。
- 套接字scoket
- 套接字是一种机制,用于在不同的进程之间交换数据,可以双向发送数据
- 线程间的同步方法大体可分为两类:用户模式和内核模式。
- 内核模式:利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态
- 事件,信号量,互斥量
- 用户模式:不需要切换到内核态,只在用户态完成操作。
- 原子操作(例如一个单一的全局变量),临界区
- 内核模式:利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态
缓存cache
cache miss
- 现代操作系统采用虚拟内存的管理机制,系统在访问某一内存页时需要首先利用页表机制将虚拟地址映射到物理地址,如果在查找的过程中发现页表中没有对应的映射条目,就会发成缺页异常
- 页表通常存储在物理内存中,类似于缓存机制,常用的页表项会被加载到 TLB (Translation Look-aside Buffer)中以加快页表的访问。内存映射单元(MMU)会优先访问 TLB 获取物理地址映射,但是如果 TLB 不命中将导致 MMU 需要从内存中加载页表项,因此 TLB 不命中势必会影响程序的执行效率
- 利用 malloc 和 free 等函数在堆空间中申请和释放动态内存
- 缺页处理程序:如果此时页表项存在磁盘地址,则需要分配一个物理页并将内容从磁盘重新拷贝进物理内存,建立起映射关系(此时成为major fault,耗时较高);如果页表项为空,则直接分配物理页并建立映射关系(此时成为 minor fault)
- 虚拟内存管理中,每一个进程都有一个独立的虚拟内存空间,每一个进程的页表项都常驻内存。
- MMU 是处理器中集成的一个专门用于地址管理的硬件。
cacheline
- CPU 上的 cacheline 大小是 64B、(NVIDIA) GPU 上是128 B,内存带宽的瓶颈往往是 last-level cache (LLC,比如 CPU 上的 L3 cache、GPU 上的 L2 cache) 到 main memory(也就是内存条)的 bandwidth
- cache分成多个组,每个组分成多个行,linesize是cache的基本单位,从主存向cache迁移数据都是按照linesize为单位替换的
- Cache总大小为32KB,8路组相连(每组有8个line),每个line的大小linesize为64Byte,可以很轻易的算出一共有32K/8/64=64 个组
写回、写穿透
- Write-Through 写穿透
- 每一次数据都要写入到主内存里面
- 写入前,我们会先去判断数据是否已经在 Cache 里面了。如果数据已经在 Cache 里面了,我们先把数据写入更新到 Cache 里面,再写入到主内存里面;如果数据不在 Cache 里,我们就只更新主内存。
- Write-Back 写回
- 当缓存数据被修改时,只在Cache中更新数据,不立即写回主存
- 缓冲内存在总线不塞车的时候,才把数据写回 DRAM
- Write-Through 缓存方法利用了高速缓存中的数据始终与主存储器中数据匹配的特点。但是,需要的总线周期却非常耗时,从而降低性能。
- Write-Back 缓存可以维持性能,因为写入始终是在“爆发”中进行的,因而运行所需的总线周期将大大减少。
L1 L2 共享内存
- L1 速度最快(1-2个时钟周期),容量最小,保证数据一致性
- L2 速度比L1慢(10-20个时钟周期),比全局内存快,容量比L1大,不同GPU_SM之间的数据访问是同步共享的
- 共享内存 容量小(比L1大一些),速度快(和L1差不多),内存需要程序员显式管理,同线程内数据一致,不同线程数据不一致,主要是为了减少全局内存访问
进程内存空间
32/64位系统
- 32位开发环境中,程序可以访问的最大内存地址空间位2^32,约4G;
- 直接处理32位寄存器和数据
- 32位程序可以在32、64位系统运行,但是不能充分利用64位的系统资源
- 64位开发环境中,程序可以访问的最大内存地址空间位2^64,约16EB
- 64位程序仅可在64位系统运行
cpp中数组内存连续?
- 是的,为了高效访问,在C++中,数组的地址是虚拟地址连续的
- 虚拟地址通过内存管理的单元MMU映射到物理地址,使CPU高效进行内存访问和缓存预取,灵活释放和分配
- 但是数组在物理地址上不一定是连续的,可能会映射到不同的物理页面
ELF内存
- Linux下进程虚拟内存,例如32位下是4GB,其中高地址1GB是内核空间,低地址3GB为用户空间
- ELF格式组成:文件头、节点表、程序头表、数据
- 代码段Text Segment:
- 存储程序的可执行代码指令
- 只读的,防止运行时修改
- 数据段Data Segment:
- 已经初始化的静态变量(包括全局静态变量和局部静态变量)、全局变量、常量数据
- 这些变量在程序的整个生命周期内都存在,其内存分配发生在程序启动前,并在程序结束时释放。
- 存储程序中的常量数据是只读的,在程序执行期间不可被修改
- BSS段:
- 未初始化的静态变量和全局变量
- 在程序开始执行之前,操作系统或编译器会将这部分内存初始化为0或空指针。
- 堆Heap:
- 动态分配内存,通过malloc、new等函数或运算符在堆上分配内存,并通过free、delete等函数或运算符释放内存。
- 堆内存的大小不固定,可以在程序运行时动态扩展,需要手动管理堆内存,容易引发内存泄漏、双重释放等问题。
- 动态申请的内存从下往上增长。大小比栈大很多,通过brk值设置
- 栈Stack:
- 用于存储局部变量、函数调用信息(如返回地址、调用者的环境信息等)以及一些临时数据。
- 从上往下增长
- 栈内存由编译器自动管理,遵循后进先出(LIFO)的原则。在函数调用时分配内存,函数返回时释放内存。
- 栈大小一般是8M,是可以设置的。 参数过多可能会栈溢出(超过了栈的应用空间)
- Memory Mapping Segment段:
- 内存映射段,可以将内存映射到磁盘的某些空间或者动态链接库。
- 内存映射段,可以将内存映射到磁盘的某些空间或者动态链接库。
其他内存
- 线程存储区TLS(C++11及以后)
- 用于存储线程局部存储(Thread Local Storage, TLS)变量。这些变量的生命周期与所属的线程一样长,可以在线程内部访问和修改。
- 线程存储区提供了线程间数据的隔离性,使得每个线程都有自己的变量副本,从而避免了线程间的数据竞争和同步问题。
- 虚函数表放在数据段,具体的虚函数放在代码段里。
- 内存缺页发生在哪里?
- 在3GB的用户空间。整个4GB是虚假的内存空间,真正的进程运行时,通过缺页中断,段页管理等映射到物理内存
- 多线程中的栈是私有的,堆是共有的
数据结构方式
- 看如下两种数据存储方式:
struct AoSData
{
public int a; //a[1] AoSData[100]
public int b;
public int c;
public int d;
}
struct SoAData
{
public NativeArray<int> aArray; // aArray[100] SoAData[1]
public NativeArray<int> bArray;
public NativeArray<int> cArray;
public NativeArray<int> dArray;
}
- 如果你的访问是顺序访问,那么 SOA 通常比 AOS 效率高一些。特别是当你的程序在 GPU 上运行的时候,因为 Coalescing
- 如果你的访问是随机访问,那么 AOS 常常比 SOA 更好。因为随机访问的一个粒子的数据在 AOS 下是在同一个 cacheline 中,而在 SOA 布局下会分散在 N 个 cachelines 里面,对于 AOS 的 cacheline utilization(缓存行利用率)会高很多。
Pthread
- pthread并非Linux系统的默认库,而是POSIX线程库,所以编译时需要加上-pthread
- 线程创建
- 需要传入pthread指针、线程的属性(调度策略、线程栈大小)、执行函数、执行函数参数
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
- 线程退出
void pthread_exit(void *value_ptr);
- 等待某个线程结束
int pthread_join(pthread_t thread, void **retval);
- 实例,多线程计算 π,要求:适应 N 核心的 CPU,不能使用全局变量,必须通过传递参数与 join 获取返回值实现
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
const int N = 1e3;
const int NR_CPU = 8;
typedef struct { int start, end; } param_t;
typedef struct { double value; } result_t;
void *worker(void *arg)
{
param_t *p = (param_t *)arg;
int i = p->start;
result_t *res = malloc(sizeof(result_t));
for (; i < p->end; i++)
{
if (i % 2) res->value += 1.0 / (2 * i - 1);
else res->value -= 1.0 / (2 * i - 1);
}
return res;
}
int main()
{
param_t params[NR_CPU];
pthread_t pids[NR_CPU] = {0};
const int step = N / NR_CPU;
int i = 0;
for (; i < NR_CPU; i++)
{
params[i].start = i * step + 1;
params[i].end = params[i].start + step;
}
params[NR_CPU - 1].end = N;
for (i = 0; i < NR_CPU; i++) pthread_create(&pids[i], NULL, worker, ¶ms[i]);
result_t *res = NULL;
double pi = 0.0;
for (i = 0; i < NR_CPU; i++)
{
pthread_join(pids[i], (void **)&res);
pi += res->value;
if (res) free(res), res = NULL;
}
pi *= 4;
printf("PI = %f\n", pi);
return 0;
}
线程调度
- 线程调度器会维护一个可运行的线程列表,最高优先级作为下一个调度的运行线程
- 实时调度的优先级 > 分时调度的优先级
- 分时调度
- SCHED_OTHER
- Linux系统的默认调度策略
- 线程静态优先级都是0
- 调度器基于动态优先级调度,动态优先级与线程的NICE值相关,nice值会随着运行动态变化,以确保线程公平,nice值越大,优先级越低
- nice函数是Linux系统优先级调整器,以定义线程优先级,根据当前线程的CPU使用率动态调整nice值,以实现负载均衡
- Top命令的NI值就表示nice值
- Top命令的PR值可以控制线程的优先级反转,当高优先级阻塞时,调度器会降低其优先级,使较低优先级的线程有机会获得CPU时间片;如果一个低优先线程阻塞,则会提高其优先级,避免长时间占用CPU资源
- PR和NI的差值是20
- SCHED_OTHER
- 实时调度
- SCHED_FIFO
- FIFO first in first out,先入先出原则
- 其会抢占时间片资源,直到有更高静态优先级的任务到达或退出
- 适合保证服务时间或实时性较高的优先级任务
- SCHED_RR
- RR,round robin,增加时间片轮转机制
- 该属性的线程会在运行时分配一个最大的时间片,当时间片资源用完后,系统会出现分配时间片资源
- SCHED_FIFO
OpenMP
- OpenMP 是基于共享内存模式的一种并行编程模型。
- OpenMP 是以线程为基础的,其执行模式采用fork-join的方式,其中fork创建新线程或者唤醒已有的线程,join将多个线程合并。
- 在程序执行的时候,只有主线程在运行,当遇到需要并行计算的区域,会派生出线程来并行执行, 在并行执行的时候, 主线程和派生线程共同工作, 在并行代码结束后, 派生线程退出或者挂起,不再工作,控制流程回到单独的线程中。
- OpenMP适用于单台计算机上的多核并行计算。通过在代码中插入指令,开发者可以指示并行执行,并将任务分配给多个处理器核心。
- OpenMP适用于需要在单个计算节点上进行并行计算的场景,如多核处理器、多线程编程等。
多线程问题排查
多线程问题定位方法
- 方法1:增加线程名称
- Linux系统中有设置进程的相关操作
prctl(PT_SET_NAME,"thread_xxx_name");
设置线程名称以更好的定位问题
- 方法2:使用智能指针
- 局部变量导致的栈内存越界问题会难以定位,可以修改局部变量为智能指针,使其在堆中分配内存
- 栈内存生命周期仅限于函数调用的持续期间,使得调试时难以追踪到具体的内存越界发生位置;栈内存的大小通常有限,容易导致栈溢出
- 使用智能指针(如C++中的std::unique_ptr或std::shared_ptr)可以自动管理内存,减少内存泄漏的风险。智能指针会在其析构函数中释放所管理的内存,这使得内存管理更加安全和便捷。
auto data = std:make_shared<DATA>();
- 方法3:ASAN定位
- 通过ASAN打印的日志往往可以定位到哪里出现的多线程问题(通常会表现为内存问题)
- 例如两个线程对同一块内存进行了操作,其中一个线程释放了这块内存,但另一个线程却使用了这块内存,就会导致use_after_free的报错。多线程写数据一定要加锁。
多线程问题排查命令
- dmesg
- 查看内核的活动
- Linux内核日志存储在一个ring-buffe
- 设备初始化日志、内核模块日志、应用崩溃信息日志
- addr2line
- 程序异常中断,查看系统dmesg信息,可以看到系统日志的错误信息
- 使用addr2line就可以将dmesg信息ip字段后面的数字转换成出错程序的位
- objdump
- 查看目标文件或者可执行的目标文件的构成
- readelf
- readelf 解析 ELF 文件比 objdump 更加详细
- strace
- 查看用户空间进程和内核的交互,比如系统调用、信号传递、进程状态变更