1.线程封装
整个封装过程中最重要的就在于对 pthread_create 参数的处理:
(1) 不能使用常规的成员函数
因为常规的成员函数的参数中还有一个 this 指针从而出现参数不匹配的问题,因此调用的只能是静态成员函数。但 static 函数自身没有 this 指针,因此参数中要接收到同类型的指针。
例:
cpp
struct Thread
{
using func_t = std::function<void(int)>;
static void* routine(void* arg)
{
//这里将其强转就能充当this指针了
Thread * self = static_cast<Thread*>(arg);
self->_func(self->_data);
}
bool start()
{
pthread_t tid;
//只能传static函数,接收的参数就是this指针即可
pthread_create(&tid,nullptr,routine,this);
return true;
}
private:
//_func是外部传进来的函数,_data是其的参数
func_t _func;
int _data;
};
这样写能发现传进去的函数返回值和参数类型就没有限制了,可以说哪个 static 成员函数充当了中间商以保证传进来的函数的自由。
整体代码:
cpp
// 模版形式的
namespace ThreadModule
{
static int number = 1;
enum class TSTATUS
{
NEW,
RUNNING,
STOP
};
template <typename T>
class Thread
{
using func_t = std::function<void(T)>;
private:
// 成员⽅法
static void *Routine(void *args)
{
Thread<T> *t = static_cast<Thread<T> *>(args);
t->_status = TSTATUS::RUNNING;
t->_func(t->_data);
return nullptr;
}
void EnableDetach() { _joinable = false; }
public:
Thread(func_t func, T data)
:_func(func)
,_data(data)
,_status(TSTATUS::NEW)
,_joinable(true)
{
_name = "Thread-" + std::to_string(number++);
_pid = getpid();
}
//生成新线程执行函数
bool Start()
{
if (_status != TSTATUS::RUNNING)
{
int n = ::pthread_create(&_tid, nullptr, Routine, this);
if (n != 0) {return false;}
return true;
}
return false;
}
//主线程对新线程进行取消的操作
bool Stop()
{
if (_status == TSTATUS::RUNNING)
{
int n = ::pthread_cancel(_tid);
if (n != 0) {return false;}
_status = TSTATUS::STOP;
return true;
}
return false;
}
//主线程回收新线程
bool Join()
{
if (_joinable)
{
int n = ::pthread_join(_tid, nullptr);
if (n != 0) {return false;}
_status = TSTATUS::STOP;
return true;
}
return false;
}
//线程自己分离自己
void Detach()
{
EnableDetach();
::pthread_detach(_tid);
}
bool IsJoinable() { return _joinable; }
std::string Name() { return _name; }
~Thread()
{}
private:
std::string _name;
pthread_t _tid;
pid_t _pid;
bool _joinable; // 是否是分离的,默认不是
func_t _func;
TSTATUS _status;
T _data;
};
}
线程的局部存储
一个变量进行 __thread 关键词修饰时,任何线程在使用到该变量时会在自己的局部存储区中存储一份该变量单独使用,从而避免了一个变量同时被多个线程同时使用。
cpp
__thread int a = 10;
多数情况下用于全局变量,让该线程在使用该全局变量时不被其他线程看到。这个局部存储区中只能存储内置类型和部分指针。
c
//name由我们传,可以给指定的thread设置名字,这个名字是存储在局部存储区的
int pthread_setname_np(pthread_t thread,const char*name);
//获取thread的名字存储到name变量中,len是其的最大存储长度
int pthread_getname_np(pthread_t thread,char*name,size_t len);
验证:__thread 变量的线程独立性
c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
__thread int tls_val = 0;
void *worker(void *arg)
{
int id = *(int *)arg;
tls_val = id * 100;
sleep(1);
printf("[thread %d] tls_val = %d\n", id, tls_val);
return NULL;
}
int main()
{
pthread_t t1, t2;
int id1 = 1, id2 = 2;
pthread_create(&t1, NULL, worker, &id1);
pthread_create(&t2, NULL, worker, &id2);
tls_val = 999;
printf("[main] tls_val = %d\n", tls_val);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("[main] tls_val = %d (still)\n", tls_val);
return 0;
}
每个执行流的 tls_val 互不干扰,验证了 __thread 变量是线程独立副本。
2.线程的同步与互斥
意义:解决临界资源造成的数据不一致问题的。
互斥
例子:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 )
{
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
return NULL;
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
return 0;
}
所有会访问公共资源的代码段都是临界区(访问也算)。
CPU 运行 ticket-- 的汇编角度(伪代码):
asm
0xFF00 mov eax, [ticket] ; (1) 将 ticket 的值加载到寄存器
0xFF02 sub eax, 1 ; (2) 寄存器中的值减 1
0xFF04 mov [ticket], eax ; (3) 将结果写回内存
eax用于存值,EIP 指针(寄存器,x86 下为 EIP,x86-64 下为 RIP)用于存汇编语言的地址。在运行过程中 EIP 指针指向的就是当前运行的代码的地址。
其实指令也是有长度的,EIP 指针最开始指向的是程序代码的起始地址,下一行代码的地址就是当前行代码地址加上该行的指令长度。
可以发现单纯的 -- 并不是原子性的:
这里有两个线程 A、B(初始 ticket 值为 100)。A 刚运行到 (3) 时发生线程切换,此时内存中 ticket 中的值依旧为 100,但寄存器中的值为 99,EIP 指向 0xFF04,然后 A 线程在 CPU 中的上下文被存起来直到下一次调用。因此 B 线程来调度时,ticket 的值还是 100,进行完一次 -- 后 ticket 值变 99,然后线程切换,A 线程运行完 (3) 后 ticket 的值依旧为 99。因此两次 -- 实际却只小了 1,发生了数据不一致的问题。
当前认为,一条语句的底层只有一条汇编时就是原子的,否则就不是原子的。
出现负数的核心原因在于判断 ticket > 0 处:
当 ticket = 1 时恰好有多个线程进行完条件判断时就发生了线程调度,导致多个线程进入了临界区,第一个线程 -- 后第二个线程 --,最终导致负数。
全局资源没有加保护,可能会有并发问题,这种问题也叫线程安全问题。
切换掉旧线程的时间点:
(1) 时间片到了 (2) 阻塞式 IO (3) sleep 等阻塞、挂起的函数。
选择新线程的时间点:从内核态返回用户态的时候和信号检查时同时进行线程切换的检查。
验证:不加锁时出现负数和重复的竞态输出
编译运行上面的售票代码,典型输出片段:
thread 1 sells ticket: 5
thread 2 sells ticket: 5 ← 同一张票被卖了两次
thread 3 sells ticket: 3
thread 4 sells ticket: 2
thread 1 sells ticket: 1
thread 2 sells ticket: 0
thread 4 sells ticket: -1 ← 出现负数
验证了数据不一致的两个表现:重复卖同一张票、出现负数票号。
3.互斥锁
pthread_mutex_t:一个结构体,叫互斥锁或互斥量。
c
// 定义全局锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
全局变量的锁不需要用户释放,执行流运行完会自动释放。
c
//创建局部锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
局部锁要由用户自己释放。
两种锁都必须被所有线程看见,定义出来的锁所有线程都必须使用接口来申请锁:
c
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
锁本身就是临界资源,因此申请锁的操作必须是原子的。pthread_mutex_lock 申请锁成功就继续向后运行,访问临界资源;申请失败就阻塞挂起。锁的能力本质是将执行临界区代码由并行运作换成串行运作。
pthread_mutex_trylock 就是申请锁失败时就让该线程直接返回去做其他事,不阻塞等待。
验证:加锁修复售票竞态条件
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 )
{
pthread_mutex_lock(&mutex);
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
break;
}
}
return NULL;
}
int main( void )
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void*)"thread 1");
pthread_create(&t2, NULL, route, (void*)"thread 2");
pthread_create(&t3, NULL, route, (void*)"thread 3");
pthread_create(&t4, NULL, route, (void*)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
printf("final ticket = %d\n", ticket);
return 0;
}
输出:100 张票按序卖出,final ticket = 0,无负数无重复。
进程间互斥(进程间的共享资源加锁)
以 shm 为例,将一块 shm 的头部一份内存强转成 pthread_mutex_t * 类型,之后就对这块空间进行锁的初始化与销毁即可。对临界资源的保护本质就是用锁对临界区代码进行保护。需要在 pthread_mutex_init 时传入设置了 PTHREAD_PROCESS_SHARED 属性的 pthread_mutexattr_t。
理解锁
加锁之后,在临界区内部依旧会发生线程切换但并不会造成问题,因为在临界区内的线程是带着锁被切换的,此时锁依旧没有被打开,其他线程必须等该线程释放这个锁,他们才能开始竞争这个锁。在临界资源中线程的执行期间不会被打扰本身也是一种变相的原子性的表现。(也就是从被挡在外面的线程来看,内部形成只有做完了或不做了这两件是有意义的,因此具备原子性)
验证:持有锁的线程被切换,外部线程依然被挡在外面
c
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared = 0;
void *slow_worker(void *arg)
{
pthread_mutex_lock(&mutex);
printf("[worker] entered, shared=%d\n", shared);
shared = 42;
sleep(2); // 模拟长时间操作,期间可能被多次切换
printf("[worker] leaving, shared=%d\n", shared);
pthread_mutex_unlock(&mutex);
return NULL;
}
void *observer(void *arg)
{
sleep(1); // 等 worker 先拿到锁
printf("[observer] trying to lock...\n");
pthread_mutex_lock(&mutex);
printf("[observer] got lock, shared=%d\n", shared);
pthread_mutex_unlock(&mutex);
return NULL;
}
int main()
{
pthread_t w, o;
pthread_create(&w, NULL, slow_worker, NULL);
pthread_create(&o, NULL, observer, NULL);
pthread_join(w, NULL);
pthread_join(o, NULL);
return 0;
}
输出:
[worker] entered, shared=0
[observer] trying to lock...
[worker] leaving, shared=42
[observer] got lock, shared=42
observer 在 worker 持有锁期间无法进入临界区,直到 worker 释放锁后才拿到锁。worker 中间被切换了(sleep 2 秒),但因为锁还在它手里,observer 看不到 shared 从 0 变到 42 的中间过程。
锁的原理
(1) 硬件级实现:出现数据不一致的核心原因在于线程切换,切换的原因在于时间片到点,时间片变化的原因在于时钟中断,因此关闭时钟中断,从而不让线程切换也是一种解法。这是内核中自旋锁在单核系统上的实现思路------持有自旋锁期间关内核抢占。但用户态程序没有权限操作中断,且多核环境下关一个核的中断不影响其他核。
(2) 软件级实现:大多数 OS 提供了一条指令 swap 或 exchange(x86 中为 xchg),用途是将寄存器和内存单元的数据相交换,由于只有一条指令,因此具有原子性。意义就是实现互斥锁的概念。
这是一段伪代码,%al 其实是一个寄存器,lock/unlock 就是申请锁和解锁锁的伪代码,xchgb 就是 swap 或 exchange 的伪代码,return 0 就代表申请锁成功返回临界区代码处,goto lock 是给阻塞被唤醒的线程重新调用一次 lock 用的,失败就直接挂起等待了。
CPU 的硬件只有一份,但 CPU 寄存器内的数据可以有多份,每一份分别是一个执行流的上下文。
把一个变量的内容交换到 CPU 寄存器内部,本质是把该变量的内容获取到当前执行流的上下文中,而当前 CPU 寄存器存储的上下文是属于进程/线程私有的。
所以我们用 swap/exchange 将内存中的变量交换到 CPU 的寄存器中,本质就是给当前进程/线程获取锁(锁只有一个,因此要使用交换操作),mutex 的本质就是 1,因此申请锁的本质就是拥有这个 1。
因为 movb 就是将 %al 中的数据清 0,所以处理的是线程自己内部的数据,因此不用担心切换导致的数据不一致。
在真实的 Linux 用户态实现中,互斥锁底层走的是 futex(fast userspace mutex)机制------无锁竞争时完全在用户态通过原子指令完成加锁/解锁,只有发生竞争时才通过 futex() 系统调用陷入内核进行休眠等待,避免了不必要的内核态切换开销。
C++11 也有锁的机制,使用方式与 Linux 差距不大,只是变成类的封装和调用了。
模拟(RAII)(类似于智能指针的封装方式):
cpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
namespace LockModule
{
// 对锁进⾏封装,可以独⽴使⽤
class Mutex
{
public:
// 删除不要的拷⻉和赋值
Mutex(const Mutex &) = delete;
const Mutex &operator =(const Mutex &) = delete;
//创建锁
Mutex()
{
int n = pthread_mutex_init(&_mutex, nullptr);
(void)n;
}
//申请锁
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
//释放锁
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
pthread_mutex_t *GetMutexOriginal() // 获取原始指针
{
return &_mutex;
}
~Mutex()
{
int n = pthread_mutex_destroy(&_mutex);
(void)n;
}
private:
pthread_mutex_t _mutex;
};
// 采⽤RAII⻛格,进⾏锁管理,就是智能指针的方式
class LockGuard
{
public:
//构造函数就是申请锁
LockGuard(Mutex &mutex)
:_mutex(mutex)
{
_mutex.Lock();
}
//析构函数就是释放锁
~LockGuard()
{
_mutex.Unlock();
}
private:
//可以发现将一个锁封装了两层,使用这个struct封装就不用自行申请锁和释放锁了
Mutex &_mutex;
};
}
验证:LockGuard RAII 封装正确性
cpp
#include <stdio.h>
#include <pthread.h>
#include "LockGuard.hpp"
LockModule::Mutex g_mutex;
int counter = 0;
void *worker(void *arg)
{
for (int i = 0; i < 10000; i++)
{
LockModule::LockGuard guard(g_mutex);
counter++;
// guard 离开作用域自动析构,无需手动解锁
}
return NULL;
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, NULL, worker, NULL);
pthread_create(&t2, NULL, worker, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d (expect 20000)\n", counter);
return 0;
}
输出 counter = 20000,验证了 RAII 封装的正确性------LockGuard 构造时自动加锁,离开作用域时自动解锁。