【Linux】线程篇Ⅰ:线程和task_struct 执行流的理解、相关接口命令、线程异常、线程的私有和共享
对于 Linux 目前实现的 NPTL 实现而言
pthread_t 类型的线程 ID,本质就是一个 进程地址空间 上的一个地址。
线程篇Ⅰ中涉及到的接口,主要是原生系统库的系统级解决方案,虽然在库中实现但是跟语言一样,比语言更靠近底层罢了。而 C++ 其实是对线程库做的封装!!
虽然原生接口效率更高,但是 语言接口 有跨平台性。不确定只在 Linux 下跑的还是推荐使用语言接口。
ebp - 偏移量
进行访问或者开辟空间(ebp 是一个相对稳定的位置)/*__thread*/ int g_val = 100;
void *threadRoutine(void* args)
{
string name = static_cast<const char*>(args);
int cnt = 5;
while(cnt)
{
// 局部变量
cout << name << " : " << cnt-- << " : " << hexAddr(pthread_self()) << " &cnt: " << &cnt << endl;
// 全局变量
cout << name << " g_val: " << g_val++ << ", &g_val: " << &g_val << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3;
pthread_create(&t1, nullptr, threadRoutine, (void*)"thread 1");
pthread_create(&t2, nullptr, threadRoutine, (void*)"thread 2");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return 0
}
线程函数中的 临时变量,储存在 进程地址空间 共享区的 线程库的 线程栈 中,线程各自使用互不影响。
全局变量 储存在主线程的 已初始化数据段,其他新线程访问全局变量访问的是同一个,是并发访问。
前面 声明 __thread
(局部存储)字样的全局部变量 ,储存在已初始化数据段,并在产生新线程后,拷贝到 线程库的 线程局部存储段 中,供各自线程使用且互不影响。(由于地址空间的分布规则,全局数据被拷贝后的地址会比原来的地址大很多,如上图示)
__thread 定义的全局变量 可以应用在:
带出某一个函数 被 各个线程调用的次数
__thread 局部存储 与 static 静态变量 没有关系哦,静态变量被所有线程共享的,存在已初始化数据段。
任何一个时刻,都只允许一个执行流在进行共享资源的访问,叫做 互斥,也叫 加锁
- 我们把任何一个时刻,都只允许一个执行流在进行访问的 共享资源,叫做 临界资源。
- 任何一个线程,都有代码 访问临界资源 的叫做,临界区
- 不访问临界资源 的区域叫做,非临界区
- 控制进出临界区的手段(加锁)造就了临界资源。
加锁 可以保证一系列操作,要不做完 要不不做,这种特性叫做 原子性,临界资源是有原子性的。
pthread_mutex_t
是原生系统库给我们提供的一种数据类型,用来创建锁。
以下接口头文件相同:
#include
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数 restrict mutex:
- 需要初始化的锁名称
参数 restrict attr:
- 属性,保持默认设置为 nullptr
注意:静态或者全局的锁,可以用如下的宏直接对锁做初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int pthread_mutex_destroy(pthread_mutex_t *mutex);
参数 mutex:
- 需要销毁的锁的名称
int pthread_mutex_lock(pthread_mutex_t *mutex);
参数 mutex:
- 需要销毁的锁的名称
int pthread_mutex_trylock(pthread_mutex_t *mutex);
参数 mutex:
- 需要销毁的锁的名称
int pthread_mutex_unlock(pthread_mutex_t *mutex);
参数mutex:
- 需要解锁的锁的名称
案例:实现多线程同时抢票
// 临界资源
int tickets = 1000;
class TData
{
public:
TData(const string &name, pthread_mutex_t *mutex):_name(name), _pmutex(mutex)
{}
~TData()
{}
public:
string _name;
pthread_mutex_t *_pmutex;
};
void threadRoutine(void *args)
{
TData *td = static_cast<TData *>(args);
while (true)
{
pthread_mutex_lock(td->_pmutex);
if (tickets > 0)
{
usleep(2000);
cout << td->_name << " get a ticket: " << tickets-- << endl; // 临界区
pthread_mutex_unlock(td->_pmutex);
}
else
{
pthread_mutex_unlock(td->_pmutex);
break;
}
// 我们抢完一张票的时候,我们还要有后续的动作
// usleep(13);
}
}
int main()
{
pthread_mutex_t mutex;
pthread_mutex_init(&mutex, nullptr);
pthread_t tids[4];
int n = sizeof(tids)/sizeof(tids[0]);
for(int i = 0; i < n; i++)
{
char name[64];
snprintf(name, 64, "thread-%d", i+1);
TData *td = new TData(name, &mutex);
pthread_create(tids+i, nullptr, threadRoutine, td);
}
for(int i = 0; i < n; i++)
{
pthread_join(tids[i], nullptr);
}
pthread_mutex_destroy(&mutex);
return 0;
}
注意细节:
加锁本质就是给 临界区 加锁,凡是访问同一个 临界资源 的线程,都要进行加锁保护,而且 必须加同一把锁。加锁的粒度 要尽可能的细。
由于所有线程都必须要先看到同一把锁,锁本身就是公共资源,不过 加锁和解锁本身就是原子的,可以保证自己的安全
临界区可以是一行代码,可以是一批代码,线程仍然可能在临界区任意位置被切换,加锁并不影响这一点。
切换线程不会影响锁的安全性,比如该线程加锁后被切走了,由于这个整个临界区的原子性,没有被解锁的情况下,任何人都没有办法进入临界区。即 他人无法成功的申请到锁,因为锁被该线程拿走了。
这也正是体现互斥带来的串行化的表现,站在其他线程的角度,对其他线程有意义的状态就是:锁被我申请(持有锁),锁被我释放了(不持有锁), 原子性就体现在这
swap 或 exchange 指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性
寄存器硬件只有一套,但是寄存器内部的数据是每一个线程都要有的。寄存器 != 寄存器的内容(执行流的上下文)
加锁解锁的代码怎么执行的?
首先:在内存中创建锁对象 mutex,这是一个共享对象,里面置 1
接着,线程调用接口 pthread_mutex_lock()
// 实现接口,编译的伪代码如下
lock:
movb $0, %al // al是放线程上下文的寄存器,这里调用了线程,向自己的上下文写入 0
xchgb %al, mutex // 将al寄存器和内存中mutex里的值做交换(本质是,线程将共享数据交换到自己私有的上下文中,因为这里只有一条代码,正是这一条代码才保证了加锁的原子性)
if(al寄存器的内容 > 0)
return 0;
else
挂起等待;
goto lock;
pthread_mutex_unlock()
movb $1, %mutex // 这里直接置 1,而不是 exchange,也意在解锁可以由其他线程完成!
唤醒等待 Mutex 的线程;
return 0;
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件:
避免死锁:
避免死锁算法:
线程自旋或者挂起等待,由访问临界区要花费的时间来决定。需要结合具体的场景。
自旋就是轮询
自旋锁接口为 pthread_spin_lock
int pthread_spin_lock(pthread_spinlock_t *lock);
解锁接口为 pthread_spin_unlock
int pthread_spin_unlock(pthread_spinlock_t *lock);
初始化接口为 pthread_spin_init
int pthread_spin_init(pthread_spinlock_t *lock, int pshared); // 第二个参数为属性 默认设置为 0 即可
销毁接口为pthread_spin_destroy
int pthread_spin_destroy(pthread_spinlock_t *lock);
在安全的规则下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,这就叫线程同步!
条件变量,允许线程在 cond 中队列式等待(就是一种顺序)
int pthread_cond_init(pthread_cond_t *restrict cond,constpthread_condattr_t *restrict attr);
参数 cond:
- 要初始化的条件变量
参数 attr
- NULL
注意:可以用如下的宏直接对条件变量做初始化
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_destroy(pthread_cond_t *cond)
参数 cond:
- 要销毁的条件变量
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
参数 cond:
- 要在这个条件变量上等待
参数 mutex:
- 互斥量(因为要让线程休眠等待,不能持锁等待,注定了 pthread_cond_wait 要有锁的释放的能力)
唤醒全部:
int pthread_cond_broadcast(pthread_cond_t *cond);
唤醒(第)一个:
int pthread_cond_signal(pthread_cond_t *cond);
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者 解耦 的。
这样的模型满足了,解耦、支持并发、支持忙闲不均的优点。
三种关系:
- 生产者 & 生产者 -> 互斥关系
- 消费者 & 消费者 -> 互斥关系
- 生产者 & 消费者 -> 同步 + 互斥关系
两种角色:
- 生产者和消费者
一个交易场所:
- 缓冲区(通常是)
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)
blockQueue.hpp
#pragma once
#include
#include
#include
const int gcap = 5; // 阻塞队列的容量默认为 5
// 阻塞队列中放一个个 task 对象
template <class T>
class BlockQueue
{
public:
BlockQueue(const int cap = gcap):_cap(cap)
{
pthread_mutex_init(&_mutex, nullptr);
pthread_cond_init(&_consumerCond, nullptr);
pthread_cond_init(&_productorCond, nullptr);
}
bool isFull(){ return _q.size() == _cap; }
bool isEmpty() { return _q.empty(); }
void push(const T &in) // 生产者 push
{
pthread_mutex_lock(&_mutex);
// 细节1:一定要保证,在任何时候,都是符合条件,才进行生产,所以需要 while 判断,以免被误唤醒
while(isFull()) // 1. 我们只能在临界区内部,判断临界资源是否就绪!注定了我们在当前一定是持有锁的!
{
// 2. 要让线程进行休眠等待,不能持有锁等待!
// 3. 注定了,pthread_cond_wait要有锁的释放的能力!
pthread_cond_wait(&_productorCond, &_mutex);
// 4. 当线程醒来的时候,注定了继续从临界区内部继续运行!因为线程是在临界区被切走的!
// 5. 也就是说当线程被唤醒的时候,继续在 pthread_cond_wait 函数出向后运行,又要重新申请锁,申请成功才会彻底返回
// 其实... 这里面的内容我们都可以不关心,接口自会保证线程安全
}
// 没有满的,就要让他进行生产
_q.push(in);
// if(_q.size() >= _cap/2) // 可以加策略
pthread_cond_signal(&_consumerCond);
pthread_mutex_unlock(&_mutex);
// pthread_cond_signal(&_consumerCond);
}
void pop(T *out) // 消费者 pop
{
pthread_mutex_lock(&_mutex);
while(isEmpty())
{
pthread_cond_wait(&_consumerCond, &_mutex);
}
*out = _q.front();
_q.pop();
// 可以自行添加加策略
pthread_cond_signal(&_productorCond);
pthread_mutex_unlock(&_mutex);
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_consumerCond);
pthread_cond_destroy(&_productorCond);
}
private:
std::queue<T> _q;
int _cap;
// 我们生产和消费访问的是同一个 queue&&queue 被当做整体使用!所以可以只用一把锁!!
pthread_mutex_t _mutex;
pthread_cond_t _consumerCond; // 消费者对应的条件变量,空,wait
pthread_cond_t _productorCond; // 生产者对应的条件变量,满,wait
};
一个简单的任务:task.hpp
#pragma once
#include
#include
class Task
{
public:
Task()
{
}
Task(int x, int y, char op) : _x(x), _y(y), _op(op), _result(0), _exitCode(0)
{
}
void operator()()
{
switch (_op)
{
case '+':
_result = _x + _y;
break;
case '-':
_result = _x - _y;
break;
case '*':
_result = _x * _y;
break;
case '/':
{
if (_y == 0)
_exitCode = -1;
else
_result = _x / _y;
}
break;
case '%':
{
if (_y == 0)
_exitCode = -2;
else
_result = _x % _y;
}
break;
default:
break;
}
}
std::string formatArg() // 业务过程显示
{
return std::to_string(_x) + _op + std::to_string(_y) + "=";
}
std::string formatRes() // 错误码显示
{
return std::to_string(_result) + "(" + std::to_string(_exitCode) + ")";
}
~Task()
{}
private:
int _x;
int _y;
char _op;
int _result;
int _exitCode;
};
多线程进行:main.cc
#include "blockQueue.hpp"
#include "task.hpp"
#include
#include
#include
void *consumer(void *args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
while (true)
{
Task t;
// 1. 将数据从blockqueue中获取 -- 获取到了数据
bq->pop(&t);
t();
// 2. 结合某种业务逻辑,处理数据
std::cout << pthread_self() << " | consumer data: " << t.formatArg() << t.formatRes() << std::endl;
}
}
void *productor(void *args)
{
BlockQueue<Task> *bq = static_cast<BlockQueue<Task> *>(args);
std::string opers = "+-*/%";
while (true)
{
// sleep(1);
// 1. 先通过某种渠道获取数据
int x = rand() % 20 + 1;
int y = rand() % 10 + 1;
char op = opers[rand() % opers.size()];
// 2. 将数据推送到blockqueue -- 完成生产过程
Task t(x, y, op);
bq->push(t);
std::cout << pthread_self() << " | productor Task: " << t.formatArg() << "?" << std::endl;
}
}
int main()
{
srand((uint64_t)time(nullptr) ^ getpid());
// BlockQueue *bq = new BlockQueue();
BlockQueue<Task> *bq = new BlockQueue<Task>();
// 单生产和单消费 -> 多生产和多消费
pthread_t c[2], p[3];
pthread_create(&c[0], nullptr, consumer, bq);
pthread_create(&c[1], nullptr, consumer, bq);
pthread_create(&p[0], nullptr, productor, bq);
pthread_create(&p[1], nullptr, productor, bq);
pthread_create(&p[2], nullptr, productor, bq);
pthread_join(c[0], nullptr);
pthread_join(c[1], nullptr);
pthread_join(p[0], nullptr);
pthread_join(p[1], nullptr);
pthread_join(p[2], nullptr);
delete bq;
return 0;
}
- 信号量是资源的可访问计数器,申请信号量成功,本身就表明资源可用
- 用申请信号量失败本身表明资源不可用
本质就是把判断转化成为信号量的申请行为。
创建信号量的类型:sem_t
接口头文件:
#include
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
int sem_destroy(sem_t *sem);
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()
发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()
生产者向 tail push 数据,他只关心空间
消费者向 head pop 数据,关心数据
只要信号量不为 0,表示资源可用,表示线程可访问
只有为空和为满的时候,cp才会指向同一个位置
环形队列只要我们访问不同的区域,生产和消费行为可以同时进行
生产者伪代码如下:
sem_room: N
P(sem_room) // 申请空间信号量
进行生产活动
V(sem_data)
消费者伪代码如下:
sem_data: O
P(sem_data) // 申请数据信号量
进行消费活动
V(sem_room)
什么时候用锁,什么时候用信号量呢?