目录
线程概念
线程优点
线程缺点
线程异常
线程系统编程接口
线程创建及终止
线程等待
使用线程系统接口封装一个小型的C++线程库并实现一个抢票逻辑
线程互斥
互斥量的接口
线程互斥实现原理
使用系统加锁接口封装LockGuard 实现自动化加锁
线程安全和可重入函数
常见线程安全和可重入情况
死锁
银行家算法简介
线程概念
线程是在进程内部,并且比进程更加轻量化的一种执行流。
线程是 CPU 调度的基本单位,而进程是承担系统资源的基本实体
一个进程内部可以包含多个线程,而在 LIinux 中,没有强制性的划分进程和线程的概念,线程也被叫做轻量级进程。
传统进程内部只有一个执行流;而进程内部创建了线程之后,内核中就有多个执行流了。
进程与线程的关系:
合理的使用多线程,能提高CPU执行密集型程序的执行效率和用户对IO密集型程序的使用体验!
CPU密集型程序指的是需要大量计算和处理的任务,涉及大量的数学运算、逻辑判断、数据处理等,对CPU的计算能力要求较高;IO密集型程序主要是指执行过程中需要大量的 IO 操作(大型文件多线程下载,涉及到大量的网络I/O操作和磁盘I/O操作,需要从网络中读取文件数据,并将其写入到本地磁盘中)。
CPU 是通过 PCB 对进程调度的,既然线程叫轻量级进程,那么在 Linux 中,CPU 调度线程的方式也是这样。但 进程 = 内核数据结构 + 代码和数据,相比于进程,线程就没有这么多资源了,线程共享进程的所有数据,但也有自己的数据:线程ID,一组寄存器,栈,errno,信号屏蔽字,调度优先级,所以相比于进程,它的创建更简单。
各进程之间共享的进程资源和环境有:文件描述符表,每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数),当前工作目录,用户id和组id。
线程在进程内部运行,本质是在进程地址空间内运行,所以对应调度所需要的寄存器少,且在轮转调度的时候不需要反复更新自己的线程上下文,从不需要更新缓存(cache)。
透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
虚拟地址通过页表化为物理地址详细过程
虚拟地址的32个比特位,前20个比特位是 “页表索引” ,这前10个比特位,形成了 2^10 - 1个页目录,这 2^10 - 1个页目录中,每个页目录中又存着 2^10 - 1个 “后10个比特位” 形成的数,这样计算的,页表一共可以映射 2 ^ 20 (1048576)个数,而这些数(页表码),每个数对应一个地址标识,,一个IO文件的基本大小 4KB(2^10 * 2^2),对这 2^10 个基本IO文件编号(叫页内偏移),所以这 2^20 个页表码,每个都对应着 2^10 个基本 IO 文件的大小(4KB),2^20 次方个 基本IO 文件的大小,加起来也就是我们常说的 4GB
因此,页表码 + 页内偏移 就能知道对应的文件位置!
对这 1048576 个页码建立数据结构,那么,对页表的管理,就变成了对数据结构的增删查改!
有了这种页表划分逻辑,进程就可以将地址空间合理的分配给线程!
线程优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
编写与调试一个多线程程序比单线程程序困难得,这里的难度主要指考虑一段代码需要考虑的情况更复杂。
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
线程系统编程接口
线程创建及终止
- 创建一个新的线程
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
thread:输出型参数,将线程ID作为参数返回
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数指针,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
- 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
前面的线程ID属于进程调度的范畴,叫 LWP,只在系统内部使用,当一个进程中只有一个线程时,这个线程的 LWP 就等于进程的 PID 。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create 函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
- 线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
2. 线程可以调用pthread_ exit终止自己。
3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit 函数可以获得进程的退出信息,存储在 value_ptr 指针中返回
void pthread_exit(void *value_ptr);
取消一个进程,成功返回0,失败返回错误码
int pthread_cancel(pthread_t thread);// thread 为要终止线程的 id
线程等待
为什么要进行线程等待?
- 线程终止后,内核资源并没有被释放,只有线程被等待成功后,内核资源才能被释放。
- 等待可以得到线程的退出信息
pthread_join 接口可以等待线程结束
int pthread_join(pthread_t thread, void **value_ptr);
线程以不同的方式终止,用 pthread_join 接口得到的终止状态是不同的!
1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传pthread_exit的参数。
4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
使用线程系统接口封装一个小型的C++线程库并实现一个抢票逻辑
#include <pthread.h>
#include <functional>
#include <string>
#include <iostream>
template <class T>
using func_t = std::function<void(T)>;
template <class T>
class Thread
{
public:
Thread(const std::string threadname, func_t<T> func, T data)
:_tid(0)
,_threadname(threadname)
,_isrunning(false)
,_func(func)
,_data(data)
{}
// 因为pthread 里的 函数指针 规定只能有一个 void* 类型的参数, 如果不定义为 static(类内方法)的话, 第一个参数永远是this,那么就不止一个参数了
static void* Pthread_Routine(void* args)
{
Thread* ts = static_cast<Thread*>(args);
ts->_func(ts->_data);
return nullptr;
}
bool Start()
{
int n = pthread_create(&_tid, nullptr, Pthread_Routine, this);//将 this 指针传过去, 方便一会调用函数指针使用传进来的 pthread 数据
if(n == 0)
{
_isrunning = true;
return true;
}
else return false;
}
bool Join()
{
if(!_isrunning) return true;
int n = pthread_join(_tid, nullptr);
if(n == 0) //等待成功
{
_isrunning = true;
return true;
}
return false;
}
bool IsRunning()
{
return _isrunning;
}
std::string ThreadName()
{
return _threadname;
}
~Thread()
{}
private:
pthread_t _tid;
std::string _threadname;
bool _isrunning;
func_t<T> _func;
T _data;
};
实现一个抢票逻辑
#include <iostream>
#include "pthread.hpp"
#include <cstdio>
#include <unistd.h>
std::string GetThreadName()
{
static int Number = 0;
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", ++Number);
return buffer;
}
void Print(int i)
{
std::cout << i << std::endl;
}
int ticket = 10000;
void GetTicket(std::string name)
{
while(true)
{
if(ticket > 0)
{
usleep(100);
printf("%s get a ticket %d\n", name.c_str(), ticket);
ticket--;
}
else break;
}
}
int main()
{
std::string name1 = GetThreadName();
Thread<std::string> th1(name1, GetTicket, name1);
std::string name2 = GetThreadName();
Thread<std::string> th2(name2, GetTicket, name2);
std::string name3 = GetThreadName();
Thread<std::string> th3(name3, GetTicket, name3);
std::string name4 = GetThreadName();
Thread<std::string> th4(name4, GetTicket, name4);
th1.Start();
th2.Start();
th3.Start();
th4.Start();
th1.Join();
th2.Join();
th3.Join();
th4.Join();
return 0;
}
运行结果:确实使用多线程并发完成了抢票,但依然出现了一些问题:
票竟然被抢到了 0 和负数,这是这些线程并发访问公共资源的时候产生的数据不一致问题。
- 为什么可能无法获得争取结果?
- if 语句判断条件为真以后,代码可以并发的切换到其他线程
- usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
- --ticket 操作本身就不是一个原子操作(需要三步原子操作,这当中线程可能会被切换)
要解决上述问题,需要做到一下三点:
- 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
- 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
- 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区
本质就是系统需要一把锁将公共代码保护起来,Linux 将这把锁叫互斥量。
线程互斥
临界资源:在多线程编程中,被多个线程共享的一段代码或数据结构。这些资源在同一时间只能由一个线程访问。
临界区:在进程中访问临界资源的代码。
互斥:在任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,对临界资源起保护作用。
原子性:不会被任何调度机制打断的操作,改操作只有两态,要么完成,要么未完成。
互斥量的接口
- 初始化互斥量
初始化互斥量有两种方法:
方法1,静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
方法2,动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
mutex:要初始化的互斥量
attr:NULL
- 销毁互斥量
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量,已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);
- 互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
调用 pthread_ lock 时,可能会遇到以下情况:
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
对互斥量加锁后,就能改进抢票系统了。
#include <iostream>
#include "pthread.hpp"
#include <cstdio>
#include <unistd.h>
class ThreadData
{
public:
ThreadData(const std::string name, pthread_mutex_t* pmutex)
:_ThreadName(name)
,_mutex(pmutex)
{}
public:
std::string _ThreadName;
pthread_mutex_t* _mutex;
};
std::string GetThreadName()
{
static int Number = 0;
char buffer[64];
snprintf(buffer, sizeof(buffer), "thread-%d", ++Number);
return buffer;
}
void Print(int i)
{
std::cout << i << std::endl;
}
int ticket = 10000;
void GetTicket(ThreadData* td)
{
while(true)
{
pthread_mutex_lock(td->_mutex);
if(ticket > 0)
{
usleep(10);
printf("%s get a ticket %d\n",td->_ThreadName.c_str(),ticket);
ticket--;
pthread_mutex_unlock(td->_mutex);
}
else
{
pthread_mutex_unlock(td->_mutex);
break;
}
}
}
int main()
{
pthread_mutex_t* mutex = new pthread_mutex_t;
pthread_mutex_init(mutex, nullptr);
std::string name1 = GetThreadName();
ThreadData td1(name1, mutex);
Thread<ThreadData*> th1(name1, GetTicket, &td1);
std::string name2 = GetThreadName();
ThreadData td2(name2, mutex);
Thread<ThreadData*> th2(name2, GetTicket, &td2);
std::string name3 = GetThreadName();
ThreadData td3(name3, mutex);
Thread<ThreadData*> th3(name3, GetTicket, &td3);
std::string name4 = GetThreadName();
ThreadData td4(name4, mutex);
Thread<ThreadData*> th4(name4, GetTicket, &td4);
th1.Start();
th2.Start();
th3.Start();
th4.Start();
th1.Join();
th2.Join();
th3.Join();
th4.Join();
return 0;
}
这样,当这段公共区代码被加锁后,解锁前其他的线程就不能访问这段代码了!
线程互斥实现原理
其实总结为一句话:当一个线程拿到开锁的钥匙之后,这个钥匙就通过 swap 变为了这个线程自己的上下文,就算线程被切换了,钥匙就被线程用上下文带走了,其他的线程依然无法打开这把锁!
使用系统加锁接口封装LockGuard 实现自动化加锁
#include <pthread.h>
class Mutex
{
public:
Mutex(pthread_mutex_t* lock)
:_lock(lock)
{}
void Lock()
{
pthread_mutex_lock(_lock);
}
void UnLock()
{
pthread_mutex_unlock(_lock);
}
~Mutex()
{}
private:
pthread_mutex_t* _lock;
};
class LockGuard
{
public:
LockGuard(pthread_mutex_t* lock)
:_mutex(lock)
{
_mutex.Lock();
}
~LockGuard()
{
_mutex.UnLock();
}
private:
Mutex _mutex;
};
只需要在使用时定义一个 LockGuard 对象,就不在用加锁和解锁了,作用有点像智能指针,通过对象的建立和销毁来控制加锁和解锁!
线程安全和可重入函数
线程安全
- 多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
可重入
- 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
可重入与线程安全联系
函数是可重入的,那就是线程安全的;函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题;如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
可重入函数是线程安全函数的一种;线程安全不一定是可重入的,而可重入函数则一定是线程安全的。如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
常见线程安全和可重入情况
常见线程安全情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性。
常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
要破坏死锁,只需要破坏死锁的几个必要条件之一即可!
避免死锁:在写代码时加锁顺序一致,尽量避免锁未释放的场景,尽量资源一次性分配。
资源一次性分配(One-time Allocation of Resources)是指在程序运行过程中,某个特定的资源只会被分配一次,并在之后不会再次分配。这种分配方式意味着一旦资源被分配给了一个实体(如进程或线程),它将一直保持分配状态,直到被释放。
还有一些可以避免死锁的算法:如死锁检测算法、银行家算法
银行家算法简介
银行家算法是一种用于避免死锁的资源分配算法,它是由Edsger W. Dijkstra在1965年提出的。银行家算法的目标是通过合理的资源分配来避免系统陷入死锁的状态。
银行家算法基于以下假设:
- 每个进程在开始执行之前必须声明其最大资源需求量。
- 系统中的资源数量是固定的。
- 系统能够检测到每个进程的资源请求和释放情况。
算法步骤:
- 初始化:为每个进程分配它所需要的最大资源量、已分配资源量和还需要资源量。同时初始化系统的可用资源量。
- 请求资源:当一个进程需要申请一定数量的资源时,首先检查系统的可用资源是否大于等于请求资源的数量,如果是,则进一步检查分配给该进程资源后是否仍然能够避免死锁。如果是,则分配资源给该进程,更新系统的可用资源量和进程的已分配和还需资源量。如果否,则进程需要等待。
- 执行任务:当一个进程执行完毕后,释放所有已分配资源,并将这些资源回收到系统的可用资源池中。
- 检查安全性:在每次资源请求和释放之后,系统需要进行安全性检查,判断系统是否处于安全状态。如果系统处于安全状态,则继续执行下一个进程的资源请求,否则,进程需要等待。
银行家算法的核心思想是,每个进程在申请资源时,系统需要先判断是否能够保证分配资源后,系统仍然处于安全状态。如果无法保证安全性,则不分配资源给进程,以避免死锁的发生。
银行家算法的优点是能够有效地避免死锁的发生,缺点是需要事先知道每个进程的最大资源需求量,且需要保持资源分配表的实时更新。同时,该算法可能导致资源利用率较低,因为只有当系统处于安全状态时,才会分配资源给进程。