我们今天接着来看线程:
线程的封装
我们的C++有专门的线程库,其实底层都是封装的系统调用,我们可以自己封装一个:
// 定义一个函数类型别名,用于线程执行的无参数无返回值的函数对象
using func_t = std::function<void()>;
/**
* @brief 封装线程功能的类
*
* MyThread 类用于简化POSIX线程(pthread)的使用,通过提供启动、连接线程的方法
* 以及管理线程执行的函数对象,使得线程的创建和控制更加面向对象和便捷。
*/
class MyThread
{
public:
/**
* @brief 构造函数,初始化线程基本信息
*
* @param tid 线程ID,实际在构造时未分配,Start方法中分配
* @param name 线程的名字,便于识别
* @param fun 要在线程中执行的函数对象
*/
MyThread(pthread_t tid, const std::string name, func_t fun)
: _tid(tid), _name(name), _isrunning(false), _fun(fun)
{
std::cout << "My tid is " << _tid << " " << "name is" << _name << std::endl;
}
/**
* @brief 析构函数,默认实现,根据需要可以扩展清理工作
*/
~MyThread() {}
/**
* @brief 静态成员函数,作为线程入口点
*
* @param args 线程参数,这里是指向MyThread实例的指针
* @return 线程执行结果,此处为nullptr
*/
static void* ThreadRoutine(void *args)
{
MyThread* th = static_cast<MyThread*>(args);
// 调用保存的函数对象执行线程任务
th->_fun();
return nullptr;
}
/**
* @brief 启动线程
*
* 使用pthread_create创建新线程,并设置线程运行状态为真
*/
void Start()
{
int retValue = pthread_create(&_tid, nullptr, ThreadRoutine, this);
if (retValue != 0)
{
std::cout << "Create thread failed! Error code: " << retValue << std::endl;
return;
}
else
{
_isrunning = true;
std::cout << "Thread started successfully." << std::endl;
}
}
/**
* @brief 等待线程结束(连接线程)
*
* 使用pthread_join等待线程结束,结束后更新线程运行状态为假
*/
void Join()
{
if(!_isrunning) return ;
int retValue = pthread_join(_tid, nullptr);
if(retValue == 0)
{
_isrunning = false;
}
else
{
std::cout << "Join failed! Error code: " << retValue << std::endl;
}
}
private:
// 线程标识符
pthread_t _tid;
// 线程名称
std::string _name;
// 线程是否正在运行的标志
bool _isrunning;
// 线程执行的函数对象
func_t _fun;
};
#include"thread.hpp"
void Print()
{
std::cout << "Step in "<< std::endl;
while(true)
{
std::cout << "I am running" << std::endl;
sleep(1);
}
}
int cnt = 0;
//线程名字
std::string GetName()
{
std::string name = "thread :" + std::to_string(cnt);
cnt++;
return name;
}
int main()
{
pthread_t tid = 0;
MyThread th(tid,GetName(),Print);
tid++;
th.Start();
th.Join();
return 0;
}
这里要注意一下这个函数:
这里我们的start_routine的函数指针,只能有一个void*的参数。
但是如果把static去掉,这个函数就会变成类内成员函数,就会多一个参数:
这时候编译,编译器就会报错,说函数类型不匹配:
所以我们声明为静态的,就是为了让参数匹配,这个失去了this,我们传参时,把this传进去:
然后强转为MyThread*,去调用:
线程互斥
我们之前其实遇到过这样的情况,就是多个线程,争抢公共资源,这个时候,能就会出现一些问题:
比如运行下面的这段代码,模拟抢票:
// 操作共享变量会有问题的售票系统代码
#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 nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, route, (void*)"thread 1");
pthread_create(&t2, nullptr, route, (void*)"thread 2");
pthread_create(&t3, nullptr, route, (void*)"thread 3");
pthread_create(&t4, nullptr, route, (void*)"thread 4");
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
pthread_join(t3, nullptr);
pthread_join(t4, nullptr);
}
我们发现,票竟然被卖到了负数,这是不可能的,这种情况是怎么发生的呢?
我们来看:
因为我们这里并没有对线程的访问进行控制,我们假设一个场景,4个线程都读到还剩一张票:
1号线程进入到循环内,判断ticket大于0,ticket减一,ticket变成0。
这个时候,大家要注意一点,我们进程内的数据是私有的,互相之间互不影响,所以当1号线程时间片用完,被拿下cpu时,线程2被调度:
这个时候,线程2的ticket也是大于0,这个时候ticket又会被减一,但是,因为线程2通过了判断的检查,ticket(公共资源)这时候已经为0了,但是依然会减一,这个时候,ticket就变为-1了。
这是一个简单的理论,方便大家理解。
几个概念
我们普及一些概念:
临界资源
临界资源(Critical Resource)是指在多线程或多进程系统中,同时只能被一个进程或线程访问的资源。当多个线程尝试同时访问这类资源时,可能会引发竞态条件(Race Condition),导致数据不一致或程序行为异常等问题。因此,对临界资源的访问必须进行同步控制,确保任何时候最多只有一个执行单元可以访问这些资源。
为了保护临界资源,通常会采用以下几种机制或方法:
- 互斥锁(Mutex):互斥锁是一种常用的同步工具,用于保护临界区代码,确保同一时间只有一个线程可以进入临界区。其他试图进入的线程将被阻塞,直到拥有锁的线程释放锁。
- 信号量(Semaphore):信号量可以视为一种更为灵活的锁,不仅可以实现互斥,还可以控制对某种资源的最大访问数量。当资源可用时,信号量的值会增加;当资源被占用时,信号量值减少。线程在访问资源前必须获取信号量,访问完毕后释放。
- 读写锁(Read-Write Lock):在存在大量读操作而写操作较少的场景下,读写锁能提供比互斥锁更高的并发性能。读写锁允许多个读取者同时访问资源,但写入者访问时会排斥所有其他读写者。
- 原子操作(Atomic Operations):在一些简单的场景下,可以直接使用CPU提供的原子指令(如CAS操作,Compare-and-Swap)来避免复杂的锁机制,以提高效率并减少开销。原子操作保证了操作的不可分割性,防止了数据竞争问题。
- 条件变量(Condition Variables):条件变量常与互斥锁一起使用,用于线程间的同步。一个线程可以等待某个条件变为真(wait),而另一个线程则可以在该条件满足时通知(notify)等待的线程。
比如,我们上面的100张票就是临界资源:
临界区
临界区(Critical Section)是计算机科学中的一个概念,特指在多线程或者多进程程序中,访问和修改共享资源(即临界资源)的代码段。这些代码段需要被保护起来,确保在任何给定时间只能由一个线程或进程执行,以防止并发执行时可能引发的数据不一致性、竞态条件或者其他同步问题。
以下是临界区的一些关键特点和原则:
- 互斥性:同一时刻,最多只有一个线程能够处于临界区内执行。这要求程序设计时必须实现某种形式的访问控制,常见的如使用互斥锁(Mutex)、信号量等同步原语。
- 有限性:临界区的代码应当尽可能简短,只包含对共享资源访问的必要部分,以减少其他线程等待的时间和降低死锁的风险。
- 进入等待条件:当一个线程想要进入临界区,但发现已有其他线程在执行时,它必须等待,直到临界区变为可用状态。
- 无忙等待:理想的临界区管理应避免“忙等待”现象,即线程在尝试进入临界区失败后不停循环检查,而是应让出CPU,如通过挂起线程,直到获得访问权限。
- 有限等待/公平性:在某些情况下,设计临界区的访问规则时会考虑公平性,即等待进入临界区的线程按照一定的顺序(如先进先出FIFO)获得访问权,避免某些线程长时间无法访问。
- 确保释放:无论线程因何种原因(正常执行完毕、异常或被中断)退出临界区,都必须确保同步机制正确释放,使其他等待的线程有机会进入。
上面if就是一个临界区:
原子性
原子性(Atomicity)是计算机科学中的一个基本概念,特别是在并发编程和数据库事务处理中尤为重要。它指的是一个操作或一系列操作在执行过程中不会被其他操作打断,即这些操作要么全部完成且不会受到任何干扰,要么完全不执行。换句话说,原子操作是不可分割的,其执行过程在多线程环境中表现为不可中断的一系列步骤。
在并发编程领域,确保操作的原子性对于防止数据不一致性和竞态条件至关重要。例如,一个简单的自增操作(如 counter++
)实际上涉及三个步骤:读取值、增加、写回。在多线程环境下,如果这个操作不是原子的,就可能导致两个线程几乎同时读取到相同的值,然后各自加一写回,最终counter只增加了1而不是期望的2。
为了实现原子性,可以采用以下几种方式:
- 硬件支持:现代处理器提供了原子操作指令,如test-and-set、compare-and-swap (CAS)等,这些指令可以确保在多线程环境下的操作不会被中断。
- 锁机制:如互斥锁(mutex)、自旋锁等,通过锁定资源来确保同一时间内只有一个线程可以执行特定的代码段。
- 原子变量:许多编程语言提供了原子类型或原子操作库,如C++的
std::atomic
,Java的AtomicInteger
等,这些类型的操作在语言层面保证了原子性。- 事务:在数据库系统中,事务的原子性确保了即使在执行过程中发生错误,数据库的状态也能保持一致,不会出现部分执行的结果。
我们可以用VS2019来观察++,是非原子操作:
一个简单的++,分为3步,这三步都有可能正在执行的时候,被其他的线程打断。所以会出现上面的问题。
如何解决这个问题呢,其实我们只要保证在一个时间段内,只能有一个线程进入临界区就可以了,我们首先用简单的方法来解决,锁。
互斥锁
互斥锁(Mutex,全称为 Mutual Exclusion)是一种同步工具,用于控制多个线程或进程对共享资源的访问,确保任何时刻只有一个线程可以访问该资源,从而避免并发访问导致的数据不一致性和竞态条件问题。互斥锁的基本原理和特性包括:
基本原理
- 锁定与解锁:线程在访问临界区(即需要保护的资源)之前,必须先获取锁。如果锁已被其他线程持有,则当前线程将被阻塞或等待,直至锁被释放。访问完成后,线程释放锁,允许其他等待的线程继续竞争锁。
- 互斥性:互斥锁的核心在于“互斥”,即一次只允许一个线程进入临界区。这是通过内部的锁定机制实现的,确保资源访问的排他性。
- 所有权:锁具有所有者概念,通常是由最后成功获取它的线程持有。只有锁的所有者才能释放该锁。
特性
- 原子性:获取和释放锁的操作是原子的,即不可中断,保证了操作的完整性。
- 可重入性:某些互斥锁支持可重入,意味着同一个线程可以多次获取同一把锁而不死锁。但并非所有类型的互斥锁都支持此特性。
- 优先级反转:低优先级线程持有锁时,高优先级线程可能被迫等待,导致优先级反转问题。某些系统提供了优先级继承或优先级天花板等机制来缓解此问题。
- 死锁:不当使用互斥锁可能导致死锁,例如循环等待锁或持有并等待其他资源。设计时需遵循死锁预防策略,如按序加锁、避免锁嵌套等。
- 性能考量:频繁的锁获取和释放会影响性能,尤其是在竞争激烈的场景下。无锁编程、读写锁等技术可以作为优化手段。
实现与使用
互斥锁在不同编程环境中有不同的实现,例如:
- POSIX线程(pthreads) 中,使用
pthread_mutex_t
类型及相关的pthread_mutex_init
,pthread_mutex_lock
,pthread_mutex_unlock
,pthread_mutex_destroy
函数。- Windows API 中,通过
CRITICAL_SECTION
结构体和相应的初始化、进入(EnterCriticalSection
)、离开(LeaveCriticalSection
)和删除函数。- C++11 及以后标准库 提供了
std::mutex
类,以及lock_guard
,unique_lock
等 RAII(Resource Acquisition Is Initialization)类来自动管理锁的生命周期。
正确使用互斥锁对于编写安全、可靠的多线程程序至关重要。
常见接口
在Linux中,互斥锁(Mutex)是一种用于保护共享资源的同步原语,用于防止多个线程同时访问和修改共享资源,从而导致数据不一致和竞态条件
pthread_mutex_init
:初始化一个互斥锁。
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
pthread_mutex_destroy
:销毁一个互斥锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
pthread_mutex_lock
:锁定一个互斥锁。如果互斥锁已被其他线程锁定,调用线程将阻塞,直到互斥锁被解锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);
pthread_mutex_trylock
:尝试锁定一个互斥锁。如果互斥锁已被其他线程锁定,函数将立即返回一个错误代码,而不会阻塞。
int pthread_mutex_trylock(pthread_mutex_t *mutex);
pthread_mutex_unlock
:解锁一个互斥锁。
int pthread_mutex_unlock(pthread_mutex_t *mutex);
pthread_mutexattr_init
:初始化互斥锁属性。
int pthread_mutexattr_init(pthread_mutexattr_t *attr);
pthread_mutexattr_destroy
:销毁互斥锁属性。
int pthread_mutexattr_destroy(pthread_mutexattr_t *attr);
pthread_mutexattr_settype
:设置互斥锁属性的类型。
int pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type);
pthread_mutexattr_gettype
:获取互斥锁属性的类型。
int pthread_mutexattr_gettype(const pthread_mutexattr_t *attr, int *type);
这些接口可以用于创建、销毁、锁定、解锁和配置互斥锁。在使用互斥锁时,请确保正确地初始化、锁定、解锁和销毁它们,以避免死锁和其他同步问题。
举个简单的例子
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
// 定义线程数量和每个线程执行迭代的次数
#define NUM_THREADS 2
#define NUM_ITERATIONS 100000
// 声明一个互斥锁变量
pthread_mutex_t mutex;
// 初始化一个全局计数器
int counter = 0;
// 线程执行的函数,用于递增计数器
void *increment_counter(void *arg)
{
// 每个线程执行指定次数的循环
for (int i = 0; i < NUM_ITERATIONS; i++)
{
// 加锁,确保同一时间只有一个线程能访问和修改counter
pthread_mutex_lock(&mutex);
counter++; // 原子性递增计数器
pthread_mutex_unlock(&mutex); // 解锁,允许其他线程访问
}
// 线程执行完毕返回nullptr
return nullptr;
}
int main() {
// 定义一个线程数组
pthread_t threads[NUM_THREADS];
// 初始化互斥锁,NULL参数表示使用默认属性
if (pthread_mutex_init(&mutex, nullptr) != 0)
{
printf("Mutex initialization failed.\n");
return 1; // 程序退出
}
// 循环创建指定数量的线程
for (int i = 0; i < NUM_THREADS; i++)
{
if (pthread_create(&threads[i], nullptr, increment_counter, nullptr) != 0) {
printf("Thread creation failed.\n");
return 1; // 线程创建失败则程序退出
}
}
// 等待所有线程完成它们的工作
for (int i = 0; i < NUM_THREADS; i++) {
if (pthread_join(threads[i], nullptr) != 0) {
printf("Thread join failed.\n");
return 1; // 线程等待失败则程序退出
}
}
// 使用完毕后销毁互斥锁,释放资源
if (pthread_mutex_destroy(&mutex) != 0) {
printf("Mutex destruction failed.\n");
return 1; // 销毁锁失败则程序退出
}
// 打印最终的计数器值,理论上应该是 NUM_THREADS * NUM_ITERATIONS
printf("Counter value: %d\n", counter);
return 0; // 程序正常结束
}
我们可以把互斥锁运用到抢票的逻辑中:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <iostream>
// 共享的票数变量,初始化为100张票
int ticket = 100;
// 初始化一个互斥锁,用于保护ticket变量的并发访问
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// 线程工作函数,负责售票操作
void *route(void *arg)
{
// 将传入的线程ID转换为char指针
char *id = (char*)arg;
// 无限循环,直到没有票可卖
while (1)
{
// 上锁,确保同一时间只有一个线程能访问ticket
pthread_mutex_lock(&mutex);
// 检查是否有剩余票
if (ticket > 0)
{
// 模拟售票耗时,以便观察线程调度效果
usleep(1000);
// 输出售票信息
printf("%s sells ticket:%d\n", id, ticket);
// 卖出一张票,递减ticket
ticket--;
// 完成售票,解锁
pthread_mutex_unlock(&mutex);
}
else
{
// 若无票,则解锁后退出循环
pthread_mutex_unlock(&mutex);
break;
}
}
// 线程结束,返回nullptr
return nullptr;
}
int main()
{
// 创建四个售票线程
pthread_t t1, t2, t3, t4;
pthread_create(&t1, nullptr, route, (void*)"thread 1"); // 创建线程1
pthread_create(&t2, nullptr, route, (void*)"thread 2"); // 创建线程2
pthread_create(&t3, nullptr, route, (void*)"thread 3"); // 创建线程3
pthread_create(&t4, nullptr, route, (void*)"thread 4"); // 创建线程4
// 等待所有售票线程结束
pthread_join(t1, nullptr); // 等待线程1结束
pthread_join(t2, nullptr); // 等待线程2结束
pthread_join(t3, nullptr); // 等待线程3结束
pthread_join(t4, nullptr); // 等待线程4结束
// 主线程结束,程序正常退出
return 0;
}