多线程与线程控制
线程的基本概念
线程的特点
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是"一个进程内部的控制序列。
一切进程至少都有一个执行线程。 - 线程在进程内部运行,本质是在进程地址空间内运行。
在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。 - 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
接下来我们创建一个线程:
cpp
int pthread_create(pthread_t * thread,
const pthread_attr_t * attr,
void * (*start_routine)(void *),
void * arg);
LIunx线程特点:
- 线程中只有一个为主线程其他都是工作线程
我们通过命令ps -aL
- 进程和线程关系图示:
- 在一个进程中,不管有多少个线程,每个线程都是对等的。
- 重点: 线程独有!
我们刚刚说过,每个线程都共享进程内部的数据。那么他们有没有属于自己分区呢?
- 线程ID。
- 一组寄存器。(存储每个线程的上下文信息) 栈。(每个线程都有临时的数据,需要压栈出栈)
- errno。(C语言提供的全局变量,每个线程都有自己的)
- 信号屏蔽字。
- 调度优先级。
- 进程和线程比较
进程是资源分配的基本单位
线程是调度的基本单位
线程共享进程数据,但也拥有自己的一部分数据
线程异常
一旦某一个线程出现错误,会导致整个进程直接结束。
附上测试代码
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <functional>
#include <vector>
#include <string>
#include <time.h>
using namespace std;
void* thread_run(void* arg)
{
pthread_detach(pthread_self()); // 将当前线程设置为分离状态,使线程退出时能够自动释放资源
while(1) {
cout << (char*)arg << pthread_self() << " pid:" << getpid() << endl; // 打印线程的信息
sleep(1); // 线程休眠1秒
break; // 跳出循环,只执行一次循环中的操作
}
int a = 10;
a = a / 0; // 故意引发除以零的错误,会导致线程崩溃
return (void*)10; // 线程返回值为10
}
int main()
{
pthread_t tid; // 定义线程ID变量
int ret = 0; // 定义返回值变量
ret = pthread_create(&tid, NULL, thread_run, (void*)"thread 1"); // 创建线程,传入线程ID、线程属性、线程函数和参数
if (ret != 0) // 判断线程创建是否成功
{
return -1; // 创建失败,返回错误码
}
sleep(10); // 主线程休眠10秒
pthread_cancel(tid); // 取消线程执行
cout << "new thread " << tid << " be cancled!" << endl; // 打印线程取消消息
void* tmp = NULL; // 定义临时变量用于接收线程返回值
pthread_join(tid, &tmp); // 等待线程结束,并获取线程返回值
cout << "thread qiut code:" << (long long)ret << endl; // 打印线程退出码
return 100; // 主线程返回值
}
页表
如果我们所谓的页表就只是单纯的一张表,那么这张表就需要建立232个虚拟地址和物理地址之间的映射关系,即这张表一共有232个映射表项。
以32位平台为例,其页表的映射过程如下:
选择虚拟地址的前10个比特位在页目录当中进行查找,找到对应的页表。
再选择虚拟地址的10个比特位在对应的页表当中进行查找,找到物理内存中对应页框的起始地址。
最后将虚拟地址中剩下的12个比特位作为偏移量从对应页框的起始地址处向后进行偏移,找到物理内存中某一个对应的字节数据。 相关说明:
物理内存实际是被划分成一个个4KB大小的页框的,而磁盘上的程序也是被划分成一个个4KB大小的页帧的,当内存和磁盘进行数据交换时也就是以4KB大小为单位进行加载和保存的。
4KB实际上就是212个字节,也就是说一个页框中有212个字节,而访问内存的基本大小是1字节,因此一个页框中就有212个地址,于是我们就可以将剩下的12个比特位作为偏移量,从页框的起始地址处开始向后进行偏移,从而找到物理内存中某一个对应字节数据。
这实际上就是我们所谓的二级页表,其中页目录项是一级页表,页表项是二级页表。
线程的优缺点
线程的优点
创建一个新线程的代价要比创建一个新进程小得多。
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
线程占用的资源要比进程少很多。
能充分利用多处理器的可并行数量。
在等待慢速IO操作结束的同时,程序可执行其他的计算任务。
计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
IO密集型应用,为了提高性能,将IO操作重叠,线程可以同时等待不同的IO操作。
概念说明:
计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找等。
IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等。
线程的缺点
性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低: 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说,线程之间是缺乏保护的。
缺乏访问控制: 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多。
多线程
- 创建多线程 我们用代码 创建多个线程。 和创建多进程一样,都是通过循环创建
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <functional>
#include <vector>
#include <string>
#include <time.h>
using namespace std;
// 函数指针类型,用于存储任务函数
using func_t = std::function<void()>;
// 线程数量
const int threadnum = 5;
// 线程数据类,用于传递线程信息和任务函数
class ThreadData
{
public:
// 构造函数,初始化线程名、创建时间和任务函数
ThreadData(const std::string &name, const uint64_t &ctime, func_t f)
: threadname(name), createtime(ctime), func(f)
{
}
public:
std::string threadname; // 线程名
uint64_t createtime; // 创建时间
func_t func; // 任务函数
};
// 线程执行的任务函数
void Print()
{
std::cout << "我是线程执行的大任务的一部分" << std::endl;
}
// 线程入口函数
void *ThreadRountine(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args);
while (true)
{
// 打印线程信息
std::cout << "新线程:线程名:" << td->threadname << " 创建时间:" << td->createtime << std::endl;
// 执行任务函数
td->func();
// 如果是特定线程,制造异常
if (td->threadname == "thread-4")
{
std::cout << td->threadname << " 触发了异常!!!!!" << std::endl;
// 故意制造除以零的异常
int a = 10;
a /= 0;
}
// 线程休眠1秒
sleep(1);
}
}
int main()
{
std::vector<pthread_t> pthreads; // 存储线程ID的容器
for (size_t i = 0; i < threadnum; i++)
{
char threadname[64];//开辟缓存区
snprintf(threadname, sizeof(threadname), "%s-%lu", "thread", i);
//snprintf() 像缓存区中写入固定大小的数据
pthread_t tid; // 线程ID
// 创建线程数据对象,包括线程名、创建时间和任务函数
ThreadData *td = new ThreadData(threadname, (uint64_t)time(nullptr), Print);
// 创建线程,传入线程数据对象
pthread_create(&tid, nullptr, ThreadRountine, td);
// 将线程ID存入容器中
pthreads.push_back(tid);
// 主线程休眠1秒,等待下一个线程创建
sleep(1);
}
// 输出所有线程的ID
std::cout << "线程 ID: ";
for (const auto &tid : pthreads)
{
std::cout << tid << ",";
}
std::cout << std::endl;
// 主线程持续运行
while (true)
{
std::cout << "主线程" << std::endl;
// 主线程休眠3秒
sleep(3);
}
return 0;
}
线程控制
.POSIX线程库
与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"打头的。
要使用这些函数库,要通过引入头文<pthread.h>。
链接这些线程函数库时要使用编译器命令的"-lpthread"选项。
错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分POSIX函数会这样做),而是将错误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码。对于pthreads函数的错误,建议通过返回值来判定,因为读取返回值要比读取线程内的errno变量的开销更小。
线程的创建
cpp
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
参数说明:
thread:获取创建成功的线程ID,该参数是一个输出型参数。 attr:用于设置创建线程的属性,传入NULL表示使用默认属性。
start_routine:该参数是一个函数地址,表示线程例程,即线程启动后要执行的函数。 arg:传给线程例程的参数。
返回值说明:
线程创建成功返回0,失败返回错误码。
线程传参
线程传参有一个共享问题,主线程,各个子线程共享同一个变量。
bash
```cpp
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
// 子线程执行的函数
void* MyThreadStrat(void* arg)
{
int* i=(int*)arg;
while(1)
{
cout<<"MyThreadStrat:"<<*i<<endl; // 打印子线程的参数值
sleep(1); // 子线程休眠1秒
}
return NULL;
}
int main()
{
pthread_t tid;
int i=0;
for( i=0;i<4;i++)
{
int ret=pthread_create(&tid,NULL,MyThreadStrat,(void*)&i); // 创建子线程
if(ret!=0)
{
cout<<"线程创建失败!"<<endl;
return 0;
}
}
while(1)
{
sleep(1); // 主线程休眠1秒
cout<<"i am main thread"<<endl; // 打印主线程信息
}
return 0;
}
线程id
获取线程id的两种途径:
线程退出的三种机制:
两种常见退出机制。
获取线程返回值:
资源回收---线程等待
我们之前等了进程,现在我们该等线程了。
等待线程的函数叫做pthread_join
pthread_join
函数的函数原型如下:
cpp
int pthread_join(pthread_t thread, void **retval);
参数说明:
thread:被等待线程的ID。
retval:线程退出时的退出码信息。
返回值说明:
线程等待成功返回0,失败返回错误码。
调用该函数的线程将挂起等待,直到ID为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。
总结如下:
如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED(-1)。
如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。
接下来我们测试4个用例
- 测试return返回
- 异常终止线程返回-1
注意:必须在进程结束之前,
pthread_cancel
被调用才是终止。且不是本身调用自己。
- pthread_exit的退出
分离线程
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成内存泄漏。
但如果我们不关心线程的返回值,join也是一种负担,此时我们可以将该线程进行分离,后续当线程退出时就会自动释放线程资源。
一个线程如果被分离了,这个线程依旧要使用该进程的资源,依旧在该进程内运行,甚至这个线程崩溃了一定会影响其他线程,只不过这个线程退出时不再需要主线程去join了,当这个线程退出时系统会自动回收该线程所对应的资源。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
分离线程的函数叫做pthread_detach
线程id和LWP
Linux不提供真正的线程,只提供LWP,也就意味着操作系统只需要对内核执行流LWP进行管理,而供用户使用的线程接口等其他数据,应该由线程库自己来管理,因此管理线程时的"先描述,再组织"就应该在线程库里进行。
进程运行时动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时该进程内的所有线程都是能看到这个动态库的
我们说每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
每一个新线程在共享区都有这样一块区域对其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息。
上面我们所用的各种线程函数,本质都是在库内部对线程属性进行的各种操作,最后将要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理本质是在共享区的。
封装一个线程库
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <functional>
#include <vector>
#include <string>
#include <time.h>
#include<string>
using namespace std;
// 定义一个模板函数类型,接收一个类型为T的参数,没有返回值
template <class T>
using func_t = function<void(T)>;
// 定义一个模板类Thread,用于封装线程操作
template <class T>
class Thread
{
public:
// 线程执行的函数,用于pthread_create中的start_routine参数
static void* ThreadRotine(void* args)
{
// 将传入的void*类型参数转换为Thread类的指针
Thread* ts = static_cast<Thread*>(args);
// 调用Thread类对象的_func成员,传入_data数据
ts->_func(ts->_data);
// 线程执行完毕,返回nullptr
return nullptr;
}
// 构造函数,接收一个函数、一个数据以及一个线程名(默认为生成的线程名)
Thread(func_t<T> func, const T& data, const string &threadname = GetThread())
: _tid(0), _isrunning(false), _func(func), _threadname(threadname), _data(data)
{
// 初始化线程ID为0,_isrunning为false,_func为传入的函数,_threadname为线程名,_data为传入的数据
}
// 启动线程
bool Start()
{
// 使用pthread_create创建线程,并将线程ID保存到_tid中
int n = pthread_create(&_tid, nullptr, ThreadRotine, this);
// 输出线程ID
cout << _tid << endl;
// 如果pthread_create成功返回0,则设置_isrunning为true并返回true
if (n == 0)
{
_isrunning = true;
return true;
}
// 否则返回false
else
{
return false;
}
}
// 等待线程结束
bool join()
{
// 如果线程没有运行,则直接返回true
if (!_isrunning) return true;
// 使用pthread_join等待线程结束
int n = pthread_join(_tid, nullptr);
// 如果pthread_join成功返回0,则设置_isrunning为false并返回true
if (n == 0)
{
_isrunning = false;
return true;
}
// 否则返回false
return false;
}
// 判断线程是否正在运行
bool IsRunning()
{
// 返回_isrunning的值
return _isrunning;
}
// 获取线程名
string Threadname_()
{
// 返回_threadname的值
return _threadname;
}
// 析构函数
~Thread()
{
// 在析构函数中,通常可以添加线程清理的代码,但这里为空
}
private:
// 静态成员函数,用于生成线程名
static string GetThread()
{
// 静态变量number,用于记录生成的线程名数量
static int number = 1;
// 定义一个字符数组name,用于保存生成的线程名
char name[64];
// 使用snprintf将生成的线程名保存到name中,格式为"Thread-编号"
snprintf(name, sizeof(name), "Thread-%d", number++);
// 返回生成的线程名
return name;
}
// 线程ID
pthread_t _tid;
// 线程名
string _threadname;
// 线程是否正在运行的标志
bool _isrunning;
// 线程执行的函数
func_t<T> _func;
// 线程执行函数所需的数据
T _data;
};
线程互斥和线程同步
线程互斥基本原理
首选需要介绍一个概念:原子性 。
原子性是指要么我不做,要么就要做完。
临界资源:临界区都能访问的资源就是临界资源。
临界区:代码访问资源时的区域称为临界区。
接下来我们用一个多线程买票的案例:
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <functional>
#include <vector>
#include <string>
#include <time.h>
#include<string>
#include <mutex>
using namespace std;
#define NUM 5
class threadData
{
public:
threadData(int number,int Sum=0)
:sum(Sum),k(number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
int sum;//抢票张数
int k;
};
int tickets = 1000; // 用多线程,模拟一轮抢票
// 互斥锁
//pthread_mutex_t _mutex=PTHREAD_MUTEX_INITIALIZER;
void *getTicket(void *args)
{
srand(((unsigned int)time(NULL)));
threadData *td = static_cast<threadData *>(args);//类型转换 由void*到threadData*
const char *name = td->threadname.c_str();
while (true)
{
// pthread_mutex_lock(&_mutex); // 加锁
if(tickets > 0)
{
usleep(1000);
int p=rand()%200;
tickets--;
td->sum++;
if(p%2==0)
{
sleep(1);
cout<<"my name:"<<name<<' '<<"I stop:"<<p<<endl;
}
cout<<"my name:"<<name<<' '<<"I get:"<<td->sum<<endl;
// pthread_mutex_unlock(&_mutex); // 解锁
}
else
{
// pthread_mutex_unlock(&_mutex); // 解锁
break;
}
}
printf("%s ... quit\n", name);
return nullptr;
}
int main()
{
vector<pthread_t> tids;//创建一个进程id容器
vector<threadData *> thread_datas;// 线程名字容器
for (int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData *td = new threadData(i);//new返回对象指针,回调函数类型也需要传指针
thread_datas.push_back(td);//线程id入容器
pthread_create(&tid, nullptr, getTicket, thread_datas[i - 1]);//创建线程 将函数名传入回调函数
tids.push_back(tid);
}
for (auto thread : tids)
{
pthread_join(thread, nullptr);
}
for (auto td : thread_datas)
{
delete td;
}
return 0;
}
我们加入随机数模拟网络延迟。 可以看到最后
每个线程抢票张数均不一样。
我们现在看一个神奇现象:
为什么我抢票抢出来负数呢?这就是线程不安全导致的,接下来我们引入一个案例
线程安全VS线程不安全
线程安全和不安全不是说线程的喔!而是说数据。因为线程是共享堆区的。在本例中全局变量大家共享。
我们先看线程安全时间:
正常情况,假设我们定义一个变量 i 这个变量 i 一定是保存在内存的栈当中的,我们要对这个变量 i
进行计算的时候,是CPU(两大核心功能:算术运算和逻辑运算)来计算的,假设要对变量 i = 10 进行 +1 操作,首先要将内存栈中的 i的值为 10 告知给寄存器,此时,寄存器中就有一个值 10,让后让CPU对寄存器中的这个 10 进行 +1 操作,CPU +1
操作完毕后,将结果 11 回写到寄存器当中,此时寄存器中的值被改为 11,然后将寄存器中的值回写到内存当中,此时 i 的值为 11。
接下来我们看一种情况:我们在在静态区开辟一个空间存放字符串,然后返回字符串。
cpp
// 定义一个名为getchar的函数,它接收一个void指针作为参数,并返回一个void指针。
// 通常这种函数作为线程函数被pthread_create等函数调用。
void* getchar(void* args)
{
// 暂停1000微秒(即1毫秒)
usleep(1000);
// 将传入的void指针转换为threadData类型的指针
threadData *td = static_cast<threadData *>(args);
// 再次暂停1000微秒(即1毫秒)
usleep(1000);
// 增加td结构体中sum字段的值
td->sum++;
// 再次暂停1000微秒(即1毫秒)
usleep(1000);
// 在静态存储区开辟一块新的内存空间,用于存放td->threadname的内容
// 注意:这里使用静态char*是错误的,因为静态变量只会在第一次调用函数时初始化,
// 并且它的生命周期是整个程序的运行期间,而不是这个函数调用的期间。
// 这意味着每次调用这个函数时,它不会重新分配内存,而是使用同一块内存地址,
// 这可能导致数据覆盖和内存泄漏等问题。
// 正确的做法是在函数内部使用非静态的char*指针,并在不再需要时释放内存。
static char* name = new char[td->threadname.size() + 1]; // 开辟空间在静态区
// 将td->threadname的内容复制到新分配的内存中
strcpy(name, td->threadname.c_str());
// 检查td的k字段是否为偶数
if(td->k%2==0)
{
// 如果是偶数,则输出线程的名字和"I stop"的信息
cout<<"my name:"<<name<<' '<<"I stop"<<endl;
// 暂停一段时间(共5毫秒)
usleep(1000);
usleep(1000);
usleep(1000);
usleep(1000);
usleep(1000);
}
// 返回指向新分配内存的void指针
// 注意:返回局部分配的内存地址是不安全的,因为当函数返回时,该内存可能已经被释放或重新分配。
// 这将导致悬挂指针的问题,并且在尝试访问该内存时可能会导致程序崩溃。
return (void*)name;
}
执行代码后 :代码结果出现了二义性
这就是线程不安全,线程不安全实际上说的线程共享的数据不安全。
照成这个问题根本原因就是,我一件事没做完。
比如我借了个手机打王者,刚打了一半,手机被别人租走了,我赶快继续续订,但是另一个人已经上他的号了。他玩了会,时间到了,我不知道那是他的,我继续接着玩,给他上了颗星。
锁的诞生
此时为了改变这种帮别人上分的情况出现。就是多车道变单车道,让你做完才能换。我把我账号锁在上面谁都登不上他的。
要想解决这个问题:正如我说的必须我干完你才能干。
代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
接下来我们正式谈谈互斥锁:
- 互斥锁的底层是一个互斥量,而互斥量的本质就是一个计数器,计数器的取值只有两种情况,一种是 1 ,一种是 0
- 1 表示可以访问 0 不可被访问
互斥锁流程如上图所示,可以看到。
加锁时,重点提示:
1.上锁后的临界区内仍可以进程切换。
我在临界资源对应的临界区中上锁了,临界区还是多行代码,是多行代码就可以被切换。加锁 不等于 不会被切换。加锁后仍然可以切换进程,因为线程执行的加锁解锁等对应的也是代码,线程在任意代码处都可以被切换,只是线程加锁是原子的------要么你拿到了锁,要么没有
2.在我被切走的时候,绝对不会有线程进入临界区! ------因为每个线程进入临界区都必须先申请锁! !假设当前的锁被A申请走了,即便当前的线程A没有被调度,因为它是被切走的时候是抱着锁走的,其他线程想进入临界区需要先申请锁,但是已经有线程A持有锁了,则其他线程在申请时会被阻塞。即:一旦一个线程持有了锁,该线程根本就不担心任何的切换问题!对于其他线程而言,线程A访问临界区,只有没有进入和使用完毕两种状态
,才对其他线程有意义!即:对于其他线程而言,线程A访问临界区具有一定的原子性
注意:尽量不要在临界区内做耗时的事情!因为只有持有锁的线程能访问,其他线程都会阻塞等待。
3.加锁是原子的
①每一个CPU任何时刻只能有一个线程在跑
②单独的一条汇编代码是具有原子性的
线程锁的伪代码表示
画图理解:
加锁成功示意图
加锁挂起示意图:
接下来我们封装一个锁
cpp
// #pragma once 是一个预处理指令,确保头文件在一个编译单元中只被包含一次,防止重复定义。
#pragma once
// 包含必要的头文件
#include <iostream> // 引入C++标准库中的输入输出流对象
#include <pthread.h> // 引入POSIX线程库,用于多线程编程
// 定义Mutex类,这是一个简单的封装了pthread互斥锁的类
class Mutex
{
public:
// 构造函数,初始化互斥锁
Mutex()
{
pthread_mutex_init(&lock_, nullptr);
}
// 加锁函数,调用pthread_mutex_lock来锁定互斥锁
void lock()
{
pthread_mutex_lock(&lock_);
}
// 解锁函数,调用pthread_mutex_unlock来释放互斥锁
void unlock()
{
pthread_mutex_unlock(&lock_);
}
// 析构函数,销毁互斥锁
~Mutex()
{
pthread_mutex_destroy(&lock_);
}
private:
// 私有成员变量,存储互斥锁
pthread_mutex_t lock_;
};
// 定义LockGuard类,这是一个简单的RAII(资源获取即初始化)风格的锁守卫类
class LockGuard
{
public:
// 构造函数,接收一个Mutex对象的指针,并在构造函数中调用lock()进行加锁
LockGuard(Mutex *mutex) : mutex_(mutex)
{
mutex_->lock();
std::cout << "加锁成功..." << std::endl;
}
// 析构函数,在LockGuard对象生命周期结束时调用,调用unlock()进行解锁
~LockGuard()
{
mutex_->unlock();
std::cout << "解锁成功...." << std::endl;
}
private:
// 私有成员变量,存储Mutex对象的指针
Mutex *mutex_;
};
可重入VS线程安全
线程安全概念
线程安全指的是在多线程编程中,多个线程对临界资源进行争抢访问而不会造成数据二义或程序逻辑混乱的情况。
线程安全的实现,通过同步与互斥实现
具体互斥的实现可以通过互斥锁和信号量实现、而同步可以通过条件变量与信号量实现。
线程是安全的,但是线程中调用的函数不一定是可重入函数,因为线程安全指的是当前线程中对各项操作时安全的,但不表示内部调用的函数是安全的,两个之间并没有必然关系
(1)概念
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。(我们写的不加锁的抢票函数就是线程不安全函数,因为可能抢票抢到-1)
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。(90%函数是不可重入函数,带_r是可重入函数,不带_r是不可重入函数)
(2)####常见的线程不安全的情况
不保护共享变量的函数
函数状态随着被调用,状态发生变化的函数
返回指向静态变量指针的函数
调用线程不安全函数的函数
(3)常见的线程安全的情况
每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作
多个线程之间的切换不会导致该接口的执行结果存在二义性
(4)常见不可重入的情况
调用了 malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的
调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
可重入函数体内使用了静态的数据结构
(5)常见可重入的情况
不使用全局变量或静态变量
不使用用 malloc 或者 new 开辟出的空间
不调用不可重入函数
不返回静态或全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
(6)可重入与线程安全联系
函数是可重入的,那就是线程安全的
函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
(7)可重入与线程安全区别
可重入函数是线程安全函数的一种
线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。
死锁
死锁的定义
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。(即:保持着自己的锁,还要要对方的锁)
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,即:两个线程各自占用一个锁后,还竞争对方的锁,叫环路等待
解决死锁
- 解决理念
解决理念就是破坏上面四种场景,就可以解除死锁。
通俗易懂的理解死锁:
两个人 AB A正在用洗脸盆 B正在用洗脚盆。A洗完了想洗脚,B说你得把洗脸盆给我才行。A说你把洗脚盆给我我才给你洗脸盆。就尬住了。
第一个条件不可被破坏,只能破坏下面三个。
避免死锁
①破坏死锁的四个必要条件(互斥条件无法破坏,其他三个均可以破坏)
②破坏循环等待条件建议:多个线程加锁顺序一致:例如线程1和线程2都是先申请A锁后申请B锁 ③避免锁未释放的场景:用完锁尽快释放
④资源一次性分配:不要出现一个临界资源就分配一个锁,出现一个临界资源就分配一个锁,这样大概率会产生死锁。应该要集中这些临界资源使用一把锁处理。(如果进程在一次性申请其所需的全部资源成功后才运行,就不会发生死锁。因为:
资源一次性分配,也就不存在请求与保持的情况以及环路等待情况了 )
线程同步
线程同步实际上,就是访问临界资源时进行的。防止一个线程一直占有锁,进而占用临界资源。 导致的线程饥饿问题,线程饥饿问题会让一个线程占用资源过度。此时,如果加入一个队列让线程在该队列等待,执行完之后,你继续排队等待。
这样就避免了线程过度占用资源。
条件变量接口
3.条件变量函数
1.条件变量初始化
静态初始化:
cpp
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
动态初始化:
cpp
int pthread_cond_init(pthread_cond_t restrict cond, const pthread_condattr_t restrict attr);
cond:待要初始化的"条件变量"的变量,一般情况下,传递一个pthread_cond_t类型变量的地址
attr:一般情况下给NULL,采用默认属性
2.条件变量销毁
cpp
int pthread_cond_destroy(pthread_cond_t cond)
3.条件变量唤醒
cpp
int pthread_cond_signal(pthread_cond_t cond);
作用:通知PCB等待队列当中的线程,线程接收到了,则从PCB等待队列当中出队操作。 至少唤醒一个PCB等待队列当中的线程。
cpp
int pthread_cond_broadcast(pthread_cond_t cond);
唤醒所有PCB等待队列当中的线程。
4.条件变量等待
cpp
int pthread_cond_wait(pthread_cond_t restrict cond,pthread_mutex_t restrict mutex);
参数:
cond:要在这个条件变量上等待 mutex:互斥量,后面详细解释
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <functional>
#include <vector>
#include <string>
#include <time.h>
#include <string>
#include <queue>
#include <mutex>
#include <string.h>
using namespace std;
// 定义一个模板类 BQueue,用于存储类型为 T 的数据
template<class T>
class BQueue
{
// 定义默认容量常量
static const int defalitnum = 50;
public:
// 构造函数,初始化队列和相关的同步机制
BQueue(int maxcap = defalitnum)
: maxcap_(maxcap)
{
// 初始化互斥锁
pthread_mutex_init(&mutex_, nullptr);
// 初始化生产者条件变量
pthread_cond_init(&Pcond_, nullptr);
// 初始化消费者条件变量
pthread_cond_init(&Ccond_, nullptr);
// 设置队列的低水位和高水位
low_ = maxcap_ / 3;
high_ = (maxcap_ * 2) / 3;
}
// 消费者接口,从队列中取出一个元素
T pop()
{
// 加锁,确保线程安全
pthread_mutex_lock(&mutex_);
// 如果队列为空,则消费者线程阻塞等待
if (q_.size() == 0)
{
pthread_cond_wait(&Ccond_, &mutex_);
}
// 取出队列的第一个元素
T out = q_.front();
q_.pop();
// 如果队列中的元素少于低水位,则通知生产者生产
if (q_.size() < low_)
{
cout << "快点消费!" << endl;
pthread_cond_signal(&Pcond_);
}
// 解锁
pthread_mutex_unlock(&mutex_);
return out;
}
// 生产者接口,向队列中添加一个元素
void push(const T& in)
{
// 加锁,确保线程安全
pthread_mutex_lock(&mutex_);
// 如果队列已满,则生产者线程阻塞等待
if (q_.size() == maxcap_)
{
pthread_cond_wait(&Pcond_, &mutex_);
}
// 模拟生产者生产数据所需的时间
sleep(10);
// 将数据添加到队列中
q_.push(in);
// 如果队列中的元素超过高水位,则通知消费者消费
if (q_.size() > high_)
{
cout << "快点生产!" << endl;
pthread_cond_signal(&Ccond_);
}
// 解锁
pthread_mutex_unlock(&mutex_);
}
// 析构函数,销毁队列和相关的同步机制
~BQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&Ccond_);
pthread_cond_destroy(&Pcond_);
}
private:
// 存储队列元素的容器
queue<T> q_;
// 队列的最大容量
int maxcap_;
// 互斥锁,用于同步访问队列
pthread_mutex_t mutex_;
// 生产者条件变量,用于生产者线程等待和通知
pthread_cond_t Pcond_;
// 消费者条件变量,用于消费者线程等待和通知
pthread_cond_t Ccond_;
// 队列的低水位和高水位
int low_;
int high_;
};
接下来我们谈论一个问题,判断条件,锁,条件变量的位置关系。
锁必须在一切临界资源访问的最外层。因为你访问判断就是在访问临界资源,访问临界资源就意味着线程安全问题,所以锁必须是临界资源的哨兵。
生成消费者模型
生产消费者模型其实有点像管道,单线程就类似管道。
这才是真实的生产者消费模型,写入容器快,计算整理慢,就像cpu计算快,外设写入慢。所以我们设计了生产者消费者模型。该模型解决了这个问题。
生产者和生产者(互斥),消费者和消费者(互斥),生产者和消费者( 互斥/同步)------3种关系 ②生产者和消费者------由线程承担的 2种角色
③超市:内存中特定的一种内存结构(数据结构)------1个交易场所
为了便于理解,我们就叫他 < 321原则 >
基于BlockingQueue的生产者消费者模型
这个队列实际上就是管道类似产物。
bash
#include <iostream>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <chrono>
#include <atomic>
// 生产者消费者共享的队列
template <typename T>
class ProducerConsumerQueue {
public:
ProducerConsumerQueue(size_t maxSize)
: maxSize_(maxSize),
queue_(),
mutex_(),
condNotEmpty_(),
condNotFull_(),
isStopped_(false) {}
~ProducerConsumerQueue() {
Stop();
}
// 停止生产者消费者队列
void Stop() {
std::unique_lock<std::mutex> lock(mutex_);
isStopped_ = true;
condNotEmpty_.notify_all();
condNotFull_.notify_all();
}
// 生产者方法
void Produce(const T& item) {
std::unique_lock<std::mutex> lock(mutex_);
condNotFull_.wait(lock, [this] { return isStopped_ || queue_.size() < maxSize_; });
if (isStopped_) return;
queue_.push(item);
lock.unlock();
condNotEmpty_.notify_one();
}
// 消费者方法
T Consume() {
std::unique_lock<std::mutex> lock(mutex_);
condNotEmpty_.wait(lock, [this] { return isStopped_ || !queue_.empty(); });
if (isStopped_ && queue_.empty()) {
throw std::runtime_error("Queue is stopped and empty");
}
T item = queue_.front();
queue_.pop();
lock.unlock();
condNotFull_.notify_one();
return item;
}
private:
size_t maxSize_;
std::queue<T> queue_;
mutable std::mutex mutex_;
std::condition_variable condNotEmpty_;
std::condition_variable condNotFull_;
std::atomic<bool> isStopped_;
};
// 生产者函数
void Producer(ProducerConsumerQueue<int>& queue, int id) {
while (true) {
int value = id * 100;
queue.Produce(value);
std::cout << "Producer " << id << " produced " << value << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
// 消费者函数
void Consumer(ProducerConsumerQueue<int>& queue, int id) {
while (true) {
int value = queue.Consume();
std::cout << "Consumer " << id << " consumed " << value << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
}
int main() {
ProducerConsumerQueue<int> queue(10); // 创建一个容量为10的队列
// 创建两个生产者和两个消费者线程
std::thread producer1(Producer, std::ref(queue), 1);
std::thread producer2(Producer, std::ref(queue), 2);
std::thread consumer1(Consumer, std::ref(queue), 1);
std::thread consumer2(Consumer, std::ref(queue), 2);
// 让线程运行一段时间
std::this_thread::sleep_for(std::chrono::seconds(5));
// 停止队列
queue.Stop();
// 等待所有线程结束
producer1.join();
producer2.join();
consumer1.join();
consumer2.join();
return 0;
}
POSIX信号量
posix信号量和system v进程信号量类似
信号量本质是一个计数器。
定义:信号量是一个计数器,这个计数器对应的PV操作(V ++ P --)是原子的。
信号量的PV操作:V ++归还资源,P --申请资源。信号量的作用:限制进入临界区的线程个数
信号量函数接口
POSIX信号量初始化
bash
#include <semaphore.h>
int sem_init(sem_t sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享 value:信号量初始值
POSIX信号量销毁
bash
int sem_destroy(sem_t sem);
4.POSIX信号量等待
功能:等待信号量,会将信号量的值减1
bash
int sem_wait(sem_t sem);
5.POSIX信号量发布
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
bash
int sem_post(sem_t sem);
信号量的应用 ---环形队列
(1)信号量的作用
后续操作基本原则:(信号量保证满数据情况只能是消费线程先消费数据资源,数据为空的情况下生产线程先申请空间资源)
环形队列有可能访问同一个位置。什么时候会发生?------我们两个指向同一个位置的时候只有满or空的时候! ( 互斥and同步)其他时候,都指向不同的位置! (并发 )
1.数据为空:消费者不能超过生产者一>生产者先运行。
2.数据为满:生产者不能把消费者套一个圈然后继续再往后写入------消费者先运行 解释如下:
生产者:最关心的是什么资源:空间默认是N: [N, 0]
消费者:最关心的是什么资源:数据默认是0 [0,N]
bash
sem_ t roomSem=N; 剩余空间的信号量
sem_ t dataSem=0; 数据的信号量
生产线程生产,P(roomSem)申请一个空间资源 --、V(dataSem)释放一个数据资源++;
消费线程消费,P(dataSem)申请一个数据资源 --、V(roomSem)释放一个空间资源++;
即:
(1)哪个线程先运行不能保证,但是数据为空的情况下能保证生产线程先申请空间资源,因为生产者关心的空间资源计数器(信号量)默认是N,消费者关心的数据资源计数器(信号量)默认是0,即使消费者线程先运行,因为数据资源计数器默认是0,无法再P--,消费者也无法消费数据资源,消费者会被挂起。只要生产者生产完之后消费者才能消费。
(2)满数据的情况只能是消费线程先消费数据资源,同理
下面给出模拟实现代码,在本案例中,缓存区的数量由信号量严格把握!
cpp
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <functional>
#include <vector>
#include <string>
#include <time.h>
#include <string>
#include <queue>
#include <mutex>
#include <string.h>
#include <semaphore.h>
using namespace std;
// 定义环形队列的最大容量
#define MAXRQ 100
// 环形队列类
class RingQueue {
private:
// P操作:等待信号量,信号量减1
void p(sem_t &sem) {
sem_wait(&sem);
}
// V操作:发送信号量,信号量加1
void v(sem_t &sem) {
sem_post(&sem);
}
public:
// 构造函数,初始化队列和相关资源
RingQueue(int map = MAXRQ)
: _map(map), _RQ(map), _Phead(0), _Ctail(0) {
sem_init(&_Cdata_sem, 0, 0); // 初始化消费者数据信号量,初始值为0
sem_init(&_Pspace_sem, 0, _map); // 初始化生产者空间信号量,初始值为队列容量
pthread_mutex_init(&_Pmutex, nullptr); // 初始化生产者互斥锁
pthread_mutex_init(&_Cmutex, nullptr); // 初始化消费者互斥锁
}
// 生产者生产数据,并放入队列
void Push(int data) {
pthread_mutex_lock(&_Pmutex); // 生产者获取互斥锁
p(_Pspace_sem); // 等待生产者空间信号量
cout << "write:" << data << endl; // 输出生产的数据
_RQ[_Phead] = data; // 将数据放入队列
_Phead++; // 更新生产者头部位置
_Phead %= _map; // 队列满时,从头开始
v(_Cdata_sem); // 发送消费者数据信号量
pthread_mutex_unlock(&_Pmutex); // 释放生产者互斥锁
}
// 消费者从队列中取出数据
int Pop() {
pthread_mutex_lock(&_Cmutex); // 消费者获取互斥锁
p(_Cdata_sem); // 等待消费者数据信号量
int data = _RQ[_Ctail]; // 从队列中取出数据
_Ctail++; // 更新消费者尾部位置
_Ctail %= _map; // 队列空时,从头开始
v(_Pspace_sem); // 发送生产者空间信号量
pthread_mutex_unlock(&_Cmutex); // 释放消费者互斥锁
return data; // 返回取出的数据
}
// 析构函数,销毁队列和相关资源
~RingQueue() {
sem_destroy(&_Cdata_sem);
sem_destroy(&_Pspace_sem);
pthread_mutex_destroy(&_Pmutex);
pthread_mutex_destroy(&_Cmutex);
}
private:
vector<int> _RQ; // 存放数据的队列
int _map; // 队列的容量
int _Phead; // 生产者头部位置
int _Ctail; // 消费者尾部位置
sem_t _Cdata_sem; // 消费者需要数据的信号量
sem_t _Pspace_sem; // 生产者需要空间的信号量
pthread_mutex_t _Cmutex; // 消费者互斥锁
pthread_mutex_t _Pmutex; // 生产者互斥锁
string _name; // 队列名称(当前未使用)
};
class Threadname
{
public:
RingQueue thread;
string name;
};
手搓一个线程池
池化技术
概念:对执行流的预先分配,当有任务时会直接指定线程去做,而不需要再创建线程。
特点:
①多线程程序的运行效率, 是一个正态分布的结果, 线程数量从1开始增加, 随着线程数量的增加, 程序的运行效率逐渐变高,
直到线程数量达到一个临界值, 当在增加线程数量时, 程序的运行效率会减小(主要是由于频繁线程切换影响线程运行效率)
②降低资源消耗:通过重用已经创建的线程来降低线程创建和销毁的消耗(解释:线程池中更多是对已经创建的线程循环利用,因此节省了新的线程的创建与销毁的时间成本)
③提高线程的可管理性:线程池可以统一管理、分配、调优和监控(解释:线程池是一个模块化的处理思想,具有统一管理,资源分配,调整优化,监控的优点)
④降低程序的耦合程度: 提高程序的运行效率(线程池模块与任务的产生分离,可以动态的根据性能及任务数量调整线程的数量,提高程序的运行效率)
单例模式
单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象
单例模式有两种:懒汉式和饿汉式。
懒汉式创建单例对象
懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象。,否则则先执行实例化操作。
饿汉式创建单例对象
饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。