线程是进程内的一个执行分支,线程的执行粒度比进程要细。
在Linux眼中,看到的PCB比传统的进程更加轻量化,所以也叫做轻量化进程。
现在我们对于进程的定义就是一堆执行流+进程地址空间+页表这些+在物理内存中的代码和数据。
所以线程就是 多创建出来的task_struct结构体。或者我们直接把第一个叫做主线程,其他的都叫做新线程。
不同的操作系统对于线程的概念是一样的,但是它们的实现方案可能不一样。
线程创建出来后也需要被OS管理起来,在Windos中,有一套专门管理线程的方案,并把它们放在tcb结构体(thread ctrl block)中进行管理,但是Linux直接复用了进程的数据结构和管理算法,直接用task_struct来模拟线程,因为他们都是一个执行流,不过有主线程和新线程之分。Linux的管理方案耦合度低,并且比较简单,维护成本低,健壮性很强,所以Linux相对与其他操作系统就很强,所以它在服务器上跑,只要不出意外,能跑个两三年都没问题,有些系统就不行(比如Windows)。
在CPU中,只有调度执行流的概念。
线程:是OS调度的基本单位 。
进程:是承担OS分配资源的基本实体。因为页表,进程地址空间,物理内存中的代码和数据,包括执行流都是资源!
我们之前谈的进程只有一个执行流。其实进程里面可以有n个执行流。
Linux中
线程<=执行流<=进程。其中全等于的情况就是这个进程内只有一个执行流。
Linux中的执行流,叫做轻量级进程。(统一的)
所以释放一个进程就是要把它申请的资源全部释放掉(之前说的包括了执行流这些)。
线程比进程要更加轻量化,原因如下:
1.创建和释放更加轻量化(创建只是创建了一个task_struct结构体)
2.切换更加轻量化。
我们知道执行流在CPU中执行是要被切换的,如果是进程被切换,CPU中有大量的寄存器,那么与之相关的进程地址空间,页表,代码上下文和数据都要被切换,而线程切换页表和进程地址空间都不需要切换,所以线程切换只是在局部进行切换。
但是这个速度还不是决定性因素。首先线程执行本质就是进程在执行,因为线程是进程的一个执行分支。在CPU内部有一块储存硬件,叫做cache。它的存储空间相比内存很小,但是相比CPU很大。在这个进程被执行时,cache内部中缓存着进程的热数据,一个进程运行越久就会越快,因为在cache中命中率会越来越高。
因为线程很多资源是共享的,所以在线程切换时,cache可以保存其中的数据。但是如果整个进程的时间片都完了,要切换一个进程了,那么cache的数据需要重新被加载,也就是数据需要先变冷再变热。所以在同一个进程内的线程进行切换,cache的数据是可以保存而不需要重新加载的。这就是线程效率比进城高的主要原因。
不过这样的话CPU就需要知道换上来的执行流是同一个进程内部的。起始在task_struct的里面有标明身份的标识符,所以就有了主线程和新新线程之说。
1.创建一个线程的代价比创建一个进程的代价要小的多,不过前提是得有这个进程。
2.与进程之间切换相比,线程之间切换需要OS做的事情少得多。
3.线程占用的资源比进程要少得多。
4.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
5.I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
1.性能损失
这个是针对计算密集型的。
2.健壮性降低/缺乏访问控制
因为资源大部分是共享的,所以一个线程在运行时可能会影响另一个线程,比如一个线程里面发生了除零错误或者野指针错误,那么会导致整个进程挂掉。因为线程是进程的一个执行分支,发生异常后进程依旧会收到信号,那么每个线程都会收到并执行对应的方法。
3.编程难度变高。
1.进程是资源分配的基本单位。
2.线程是调度的基本单位。
3.线程也有自己的一部分数据:
线程ID,栈,一组寄存器(线程的上下文),errno,信号屏蔽字,调度优先级。
其中最重要的就是线程有独立的栈和上下文。
除了数据和代码,线程还共享以下进程资源和环境:
文件描述符表,每种信号的处理方式,当前工作目录,用户id和组id。
首先在CPU中有一个CR3寄存器,它是保存了当前执行流的页表的地址。物理内存空间是以4kb来分配的(页框),磁盘中也是(页帧)。
32位的计算机,虚拟地址的大小也就是4字节(32位比特位)。
其中这个32其实是32 = 10 + 10 + 12。这什么意思呢?
首先页表其实并不只是一块的,如果只是简单的只有一块页表,里面直接存虚拟地址向物理地址的转换,那么这个页表会非常的大,整个内存都装不下。
所以这个页表其实是被分成了两级的。
首先一级页表只有2^10个条目(也就是1024个),二级页表也有1024个条目。其中一级页表每个元素的内容就是指向二级页表 的指针。
所以前10个比特位转化成十进制后,就充当了一级页表的下标。找到二级页表后,同样用后续10个比特位转化为十进制后,找到在二级页表中对应的下标的元素。其中二级页表中的元素里存的就是在物理内存地址中,一个页框的起始地址。我们发现,最后的12个比特位2^12 = 4096,刚好就是4kb,那么找到页框的起始地址后,根据偏移量来找到最终的位置!因为页框也刚好是4kb的大小,所以不会有越界的问题。
不过二级页表大部分是不全的。
这就是虚拟地址向物理地址较为具体的转换过程。
不过我们发现,这样只能帮我们找到一个字节。但是在我们的C或者C++等语言里,一个变量的类型可能有4或者8个字节,但是我们依旧可以精准的读取数据,原因就是类型告诉了OS该找多少个字节。而不是去找4个或8个地址。
所以任何变量都只有一个地址。对于结构体和类其实也就是一堆变量(内置类型)的集合体,在代码编译成二进制后就已经没有结构体或类的概念了。但是注意空类的大小是1字节,因为它要充当占位符。
CPU中的CR3寄存器直接指向了页目录,也就是一级页表的起始地址。对任何进程来说,二级目录可以没有,但是一级必须有,因为它是页目录。
其中CPU中还有一个CR2寄存器,这里面放的就是引起缺页中断异常的虚拟地址。
线程目前分配资源,本质就是分配地址空间范围。
在我们编写代码时,代码也是有地址的,并且也是虚拟地址。
在Linux内核中没有很明确的线程的概念,只有轻量级进程的概念。所以OS不会提供线程的系统调用,只会提供轻量级进程的调用。
在安装Linux系统的时候,OS在应用层会默认自带一个pthread线程库。这里面对轻量级进程的系统调用进行了封装,为用户提供直接的线程接口。这是一个第三方库。
这个库也叫做原生线程库。很多的语言包括C++和java都对这个原生线程库做出了封装,所以在语言上我们可以直接使用语言的线程的接口来创建使用线程,使用语言的线程库的好处就是使得代码的可移植性变高,但同时对语言的依赖性也会变高。
因为这个是第三方共享库,所以即便系统已经默认自带了这个库,也包含了头文件,但是在编译的选项时,我们依然要指明要使用这个库。
makefile
threadTest:threadTest.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f threadTest
第一个参数就是输出型参数,创建成功后会返回这个线程的专属编号。其中这个pthread_t也是这个库封装的类型。
第二个参数就是设置我们要创建的线程的属性,不过这个选项我们一般设置为nullptr就可以了。
第三个参数就是函数指针了,就是我们需要这个线程要执行的什么任务,把函数名传上去即可。并且这个函数的格式是有要求的,它的返回值必须是(void*)。
关于为什么要用void*,因为如果我们想让接收返回值时做到与类型无关,就会这样设计,比如malloc的返回值就是void*,需要用户强转。但是不能是void,必须是void*,因为void不能定义变量,而且在重谈地址空间后我们也知道,任何变量只有一个地址,所以我们需要知道这个变量的大小,才能完整的读取这个变量的值。一般32位机器的地址是4字节,64位机器的地址是8字节。不过在C++中出现了模板,就不再使用void*了。
第四个参数就是将来我们需要线程调用这个函数的时候,这个函数所需要的参数。也就是说,创建线程成功,新线程需要回调线程函数的时候,需要参数,这个参数就是传递给线程函数的。没有可以设置为nullptr(NULL)。
如果线程创建成功,返回一个0,错误就会返回错误码。注意这里,它并没有用errno。 它是以返回值告诉我们错误的。
因为主线程也要起到管理新线程的作用,所以主线程要最后退出,也就是要等待新线程退出后,自己再退出
其中第一个参数就是之前的tid。表示要等待的线程。
第二个参数就是我们接收线程函数的返回值,如果我们不关心返回值,可以填nullptr。
为什么要用这样的方式来接收呢?
因为线程在做它们被分配的任务时,是在内核中执行的,用户无法跟内核中的数据进行交互,于是我们可以通过传入一个输出型的二级指针的方式,将返回值赋值到这个指针解引用后的变量,以此将返回值带回来。
这个函数就是取消这个线程,我们只需要传入这个线程的tid即可。
这个函数的逻辑有点像用kill来杀掉这个进程。
如果用这个函数杀掉了这个线程,在join的时候会得到一个-1。不过这个函数不常见。
需要注意的是,我们之前说过进程可以程序替换,线程可以吗?当然不行,如果替换成功,那么整个进程被替换了,也就没线程什么事了。
在线程运行时,我们可以用 ps -aL来查看当前OS启动的所有轻量级进程
我们发现 PID是一样的,但是有一个LWP会有些微差别,这个LWP起始就是轻量级进程id,我们知道Linux调度的基本单位是线程,也就是按这个LWP来调度的。 系统是按LWP来标识这个线程的,在用户层上是用tid来标识在这个进程里的线程的。
TTY就是终端的意思。
并且我们发现 PID和LWP相等的就是主线程,那么其他的就是新线程。
我们可以用kill命令来杀掉一个新线程
然后我们发现整个进程都退出了。所以也就验证了之前说到过的,信号是给整个进程发的。一个线程出异常了,整个进程都会退出。
#include
#include
#include//原生线程库
#include
#include
using namespace std;
void* threadRoutine(void* args)
{
const char* name = (char*)args;
int cnt = 5;
while(true)
{
cout << name << "thread id: " << getpid() << endl;
sleep(1);
if(!cnt--)
return (void*)1;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRoutine,(void*)"thread 1");
cout << "main thread create done, new thread id : " << getpid() << endl;
void* result;
pthread_join(tid,&result);
cout << "main quit ...." << (long long)result << endl;
return 0;
}
如果我们想用C++11的多线程,也需要在编译时加上 -lpthread来指定这个库。
C++11线程库的简单使用
#include//C++11线程库的简单使用
void threadrun()
{
while(true)
{
cout << "C++11 thread" << endl;
sleep(1);
}
}
int main()
{
thread t1(threadrun);
t1.join();
return 0;
}
回过头来一想,Linux是如何给我们创建轻量级进程的呢?
其中第一个参数就是是一个函数指针,它创建好的执行流就是执行的这个函数。
第二个参数就是我们需要给个执行流自定义一个栈。
第三个参数就是创建出来的进程要不要进程资源共享。默认是共享。
这个系统接口被原生线程库封装了。
对于用户层来说,线程的概念是由库来维护的。那么当我们的进程在运行的时候,这个库要加载到内存中,在进程的地址空间上位于共享区的位置。所以为什么说线程的栈是独占的,因为这个栈的空间是由在共享区里开辟的!
注意,库维护的是线程的概念,而不是执行流,task_struct是在内核中由OS维护的,因为OS只提供了轻量化进程的接口,所以线程的属性之类的字段由库来维护。线程库注定了要维护多个线程属性集合。所以库要管理线程,也就要先描述再组织。
所以线程是在OS之上的,也叫做用户级线程
如上,库在共享区里,在共享区里维护着一块一块(这里一块就代表了一个线程)的线程的属性,也可以把这一块 看作tcb。我们发现,tid值就是这个一块的起始地址!也就是说,每一个线程库级别的tcb的起始地址,就是线程的pid!
所以每一个新线程都是用的共享区里面的独立的栈,而主线程则是直接用的地址空间里的栈结构。
简单说下这个函数,就是获取当前线程的tid。
主线程中也可以调用这个函数,只不过大多字段属性没什么用。
我们在每个线程函数里面创建了一个变量,通过取地址打印发现,它们的地址各不相同。
注意,这里的变量在独立的栈里面,只能说明这个变量是独立的,而不是私有。我们任然有办法可以在其他的线程里访问这个变量。因为这个变量依旧在进程地址空间里面。 比如我们可以直接在全局定义一个指针,然后可以通过if等判断语句来拿到某个具体的线程中的变量的地址,然后在主线程中就可以访问了。
不过这种操作一般是禁止的,因为很不安全。
所以虽然线程有自己独立的栈结构,但是线程与线程之间几乎是没有秘密的,线程的栈上的数据,也可以被其他线程看到并访问。
这个全局的变量也叫做共享资源。
那么一个线程想要一个私有的全局变量也是可以的。编译器提供了一个 __thread,用它来修饰全局变量,就能做到每个线程中都有着对应的这一份全局变量,也就是给每一个线程开辟了一份。
我们发现每个地址都不一样,且看样子不在全局变量区了。 注意这个是编译器提供的。
这种技术也叫做线程的局部存储。线程的局部存储只能修饰内置类型,不能修饰自定义类型!
首先看一个示例,假设一个电影院售卖一千张票,有四个黄牛来抢票(四个新线程)。代码如下
#include
#include
#include
#include
#include
#include
using namespace std;
#define NUM 4 //四个黄牛
__thread int n = 10; // 定义一个私有的全局变量
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 1000; //一千张票
void* getTicket(void* args)
{
threadData* td = static_cast(args);
const char* name = td->threadname.c_str();
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("who=%s,get a ticket: %d\n",name,tickets);
tickets--;
}
else
break;
}
printf("%s ...quit\n",name);
cout << &n << endl; //验证这个私有的全局变量的地址
return nullptr;
}
int main()
{
vector tids;
vector thread_datas;
for(int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid,nullptr,getTicket,thread_datas[i - 1]);
tids.push_back(tid);
}
// for(auto i : tids)
// {
// pthread_detach(i);
// }
for(auto i : tids)
{
pthread_join(i,nullptr);
}
for(auto& x: thread_datas)
{
delete x;
}
cout << "main quit" << endl;
return 0;
}
makefile
threadTest:threadTest.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f threadTest
注意,我们用了一个类来封装我们需要传入线程函数的参数,这样的好处就是使得代码的扩展性变高了,我们可以传入一个字符串当参数,但是将来如果我们还需要增加属性的话就得改很多地方,如果用类来封装的话,直接在类里面增加就可以了。
我们发现等待子进程可以阻塞等待或者非阻塞等待,join等待新线程的话是阻塞等待的,如果我们不用join来等待新线程的话,就会造成内存泄漏。
但是如果我们不关心线程的返回值,并且join是一种负担时,我们可以让系统在线程退出后自动释放资源。(比如说一些长期运行的服务器)
int pthread_detach(pthread_t thread)
这个调用也很简单,只要传入需要分离的线程的tid即可。
我们可以在主线程中将某个线程进行分离,也可以线程自己将自己分离。
关于释放一个线程:就是释放PCB,库里面的这个线程的栈和线程的属性等等。页表啥的就不要管了。
注意,如果该线程已经分离了,再用join进行等待时,join就会出错,返回22。
如果主线程退了,其他的进程就算没有执行完,它们也会跟着退。
执行结果如下
我们发现票数怎么可以抢到负数呢?
首先对于一个全局变量,我们在多线程访问的时候,对它进行++或者--。这个操作是否安全呢?
肯定是不安全的。
对于全局变量tickets--,它在汇编是其实是三条语句。(tickets的值是1000)
1.先将tickets的值读入到cpu的寄存器中。
2.在CPU内部进行 -- 操作。
3.再将计算结果写回到内存。
另外我们知道,CPU中的寄存器保存的其实是被调度的线程的上下文!
假设在一个线程内,它重复了以上三个操作10次,但是在最后一次的时候,这个值还保存在寄存器中,要准备写回到内存中时,突然该线程被切换了,那么下次这个线程被换上来时,寄存器中的值应该是990。然后就到了下一个线程换上来继续做--操作,重复了操作900次,并且将值写回到了内存中,那么此时这个变量在内存中的值就是90。然后,切换到之前那个线程了,但是在它的上下文中,它刚好要执行将990的值写回到内存中,那就完蛋了,这个变量的值在内存中又变回了990。造成了数据不一致问题。
所以为什么抢票会抢到0甚至负数,就是因为这几个线程在tickets=1的时候,刚好做完了逻辑运算,进入到了if的代码块中,每一次每一个线程对tickest进行--操作时,都是从内存中读取的,所以就导致了数据不一致问题。
所以对于共享数据的访问,要保证任何时候只允许一个执行流访问。
解决方案就是要加锁。
临界资源:被多个执行流共享的资源。
临界区:每个线程的内部,访问临界资源的代码,就是临界区。
互斥:任何时刻,互斥保证这个临界区有且只有一个执行流进入,访问临界资源,通常对临界资源起保护作用。
互斥(Mutual Exclusion)是指在多个线程并发执行的情况下,通过使用互斥锁(Mutex Lock)来保证同时只有一个线程能够访问共享资源。互斥锁是一种同步机制,当一个线程获得了互斥锁后,其他线程必须等待该线程释放锁之后才能继续执行。这样可以避免多个线程同时对共享资源进行读写而导致的数据不一致和竞态条件问题。
原子性:不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
pthread_mutex_t 也是原先线程库里面封装的类型。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
这样是静态分配一个互斥量(也就是一把锁)。
或者我们也可以先声明这个变量,再动态分配。
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex:要初始化的互斥量
attr:NULL
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
1.使用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 这种方式分配互斥量的不需要销毁。
2.不要销毁一个已经加锁的互斥量。
3.已经销毁的互斥量,要确保后续不会有线程来尝试加锁。
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
如果该互斥量处于未锁状态,那么就会上锁成功,并返回0.
如果该互斥量已经被上锁,那么这个线程就会阻塞挂起来等待。
锁本身也是共享资源!
所以申请和释放锁的操作也必须是原子性的。
如果可以用这个锁来保证我们某个操作的安全性,那么首先这个锁的设计必须是安全的。
我们知道CPU其实只是运算很快,但是并不聪明,它只能明白一些简单的指令。CPU在设计的时候就设计了一套指令集,来识别一些简单的指令,这些指令的操作肯定是原子性的,对于锁的实现,用的是swap或者是exchange指令。因为只有一条指令,所以能保证原子性。
以下是lock和unlock的伪代码
首先关于lock(上锁)
第一条汇编就是将0读取到CPU中的aex寄存器中。
第二条汇编语句就是交换寄存器和在物理内存中的互斥量的值。
最后再判断如果寄存器中的值大于0,那么说明这个锁没有被其他线程上锁,于是该线程就上锁成功,返回0;否则挂起。
unlock就很简单了:
它最核心的就是直接将1放进互斥量的物理内存中。
线程安全:多个线程并发执行同一段代码时, 不会出现不同的结果。一般对静态变量或全局变量进行操作时,并且没有锁的保护,就会出现线程安全问题。
重入:同一个函数被不同的执行流调用,并且一个执行流还没有执行完,就有另一个执行流调用,就称为重入。一个函数在重入的情况下,运行结果不会出现任何问题,就叫做可重入函数,否则就是不可重入函数。
不保护共享资源的函数。
函数随着被调用,函数状态随之发生改变的函数。
返回静态变量指针的函数。
调用线程不安全的函数。
函数可重入,那么线程肯定是安全的。
函数不可重入,多个线程使用的时候,就有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,但是可重入函数一定是线程安全的。
如果对临界资源的访问上锁,则这个函数是线程安全的,但是如果这个重入函数有锁但是因未释放产生死锁问题,则是不可重入的。
#include
#include
#include
#include
#include
#include
#include "LockGuard.hpp"
using namespace std;
#define NUM 4 //四个黄牛
//__thread int n = 10; // 定义一个私有的全局变量
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 1000; //一千张票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//全局声明的互斥量(静态声明的)
void* getTicket(void* args)
{
threadData* td = static_cast(args);
const char* name = td->threadname.c_str();
while(true)
{
{
LockGuard lockguard(&lock);//临时的LockGuard的锁,RAII风格。
if(tickets > 0)
{
usleep(1000);
printf("who=%s,get a ticket: %d\n",name,tickets);
tickets--;
}
else
break;
}
}
printf("%s ...quit\n",name);
//cout << &n << endl; //验证这个私有的全局变量的地址
return nullptr;
}
int main()
{
vector tids;
vector thread_datas;
for(int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid,nullptr,getTicket,thread_datas[i - 1]);
tids.push_back(tid);
}
// for(auto i : tids)
// {
// pthread_detach(i);
// }
for(auto i : tids)
{
pthread_join(i,nullptr);
}
for(auto& x: thread_datas)
{
delete x;
}
cout << "main quit" << endl;
return 0;
}
在这个代码中,同样用了一个类对加锁进行了封装,使用的是RAII的风格的锁。
LockGuard.hpp
#pragma noce
#include
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_;
};
但是运行结果
我们发现几乎都是2号线程在抢票。这也不合理,为什么呢?
这就是线程之间的饥饿问题。
线程之间对于锁的竞争力可能会不同,比如在这个例子中,2号线程对与锁的竞争力太强了,以至于它明明已经将锁还了回去,但是又立马抢到锁了,然后就抢票。
我们可以稍加修改
while(true)
{
{
LockGuard lockguard(&lock);//临时的LockGuard的锁,RAII风格。
if(tickets > 0)
{
usleep(1000);
printf("who=%s,get a ticket: %d\n",name,tickets);
tickets--;
}
else
break;
}
usleep(15);
}
我们通过在每一次抢票完全结束后,休眠一小会的方式,让其他线程得以被切换上来,这样就使得所有线程对于锁的竞争力是均衡的。
这样执行结果就是
看起来就均衡了许多。
加锁的本质:就是用时间来换安全。
加锁的表现:使线程对于临界区代码串行执行。
加锁原则:要尽量保证临界区的代码越少越好。
注意,在线程在加锁的临界区执行代码的时候,也可能会被切走,但是它是带着锁被切走的,结合之前锁的原理很容易理解。所以在线程带锁被切走,这个临界资源任然没有任何人能访问。
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源,而处于一种永久等待的状态。
造成死锁的四个必要条件:
1.互斥条件:一个资源只能被一个执行流使用。(这是前提条件)
2.请求与保持条件:一个执行流因申请资源而阻塞时,对方获得的资源保持不放。
3.不剥夺条件:一个执行流已获得的资源,在未使用之前,不能强行剥夺。
4.循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
其中第一个条件为前提条件。
第二和第三是原则。
第四是形成死锁的重要条件。
这四个条件全部满足不一定死锁,但是死锁了这四个条件一定全部满足。
其实死锁大多数也因为程序猿的编码问题导致的,对于如何避免有以下:
1. 破坏死锁的四个必要条件。
2.加锁顺序一致。
3.避免锁未释放的场景。
4.资源一次性分配。
银行家算法的思想是为了避免出现“环路等待”条件,银行家算法的思想在于将系统运行分为两种状态:安全/非安全,有可能出现风险的都属于非安全。处于不安全状态只是表示有风险,不代表一定发生,所以银行家算法是避免出现死锁的一种算法(并非预防的方法),也就是避免策略。
鸵鸟策略 对可能出现的问题采取无视态度,前提是出现概率很低。
预防策略 破坏死锁产生的必要条件。
避免策略 银行家算法,分配资源前进行风险判断,避免风险的发生。
检测与解除死锁 分配资源时不采取措施,但是必须提供死锁的检测与解除手段。
条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了,比如一个线程要到一个队列里面领取任务,但是队列是空的,它只能等到其他线程添加任务到这个队列里面,它才能领取并执行任务,这种时候就需要用到条件变量。
同步:在保证数据安全的情况下, 让线程能按照特定的顺序来访问临界资源,从而避免饥饿问题,叫做同步。
同步(Synchronization)是指在多个线程之间协调和控制执行顺序,以确保线程之间的操作按照特定的顺序进行。常见的同步机制包括信号量(Semaphore)、条件变量(Condition Variable)和屏障(Barrier)等。通过使用这些同步机制,可以实现线程之间的通信和协作,确保线程按照预定的顺序进行执行。
比如说,一个线程刚刚执行完一个任务,它就需要唤醒下一个需要执行任务的线程,然后将自己放到队尾进行排队。中间这个就是条件变量(图中的结构体)。
条件变量必须依赖锁的使用,不然也就没什么意义了。
初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
当然条件变量也可以全局的初始化,这样也就不需要销毁了。
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
第二个参数就是传入一把锁。
唤醒等待的函数
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
简单使用:
#include
#include
#include
const int NUM = 5;
int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 全局初始化的条件变量
void *Count(void *args)
{
pthread_detach(pthread_self());
uint64_t number = (uint64_t)args;
std::cout << "pthread: " << number << " create success" << std::endl;
while (true)
{
pthread_mutex_lock(&mutex);
// 注意,我们是上锁后再进行的条件等待。
// 其目的就是为了判断临界资源是否就绪,因为判断也是访问临界资源,
// 所以需要先上锁再判断!如果资源就绪了就执行,没有就绪就释放锁,并排队。
pthread_cond_wait(&cond, &mutex);
std::cout << "pthread: " << number << " , cnt: " << ++cnt << std::endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
for(uint64_t i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
usleep(1000);
}
sleep(3);
std::cout << "main thread ctrl begin: " << std::endl;
while(true)
{
sleep(1);
pthread_cond_broadcast(&cond);
std::cout << "signal one thread..." << std::endl;
}
return 0;
}
makefile
test_cond:test_cond.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f test_cond
我们发现加入了同步之后,解决了饥饿问题。
pthread_cond_wait让线程等待的时候,会自动释放锁!如果资源不就绪,那么就等待休眠。
用pthread_cond_wait让线程等待以后,还需要今后将这个线程唤醒,才能继续执行。我们是在主线程中唤醒的。
另外如果我们将sleep(1)改为usleep(10)往小了改,那么就会出现以下结果
我们发现在显示器上打印的信息比较混乱,这是因为在Linux下,显示器也是文件,在被多个线程访问时,它也是共享资源。
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
这两个唤醒函数的区别就是,第一个是唤醒在这个条件变量中的所有线程,第二个则是唤醒一个线程,默认是第一个。我们用的是第一个,全部唤醒。
生产者消费者模型就是通过一个容器来解决生产者和消费者之间的强耦合问题。 生产者和消费者之间不直接通讯,而是通过阻塞队列进行通讯。所以生产者生产完数据后,不用等消费者处理,直接扔进阻塞队列中,消费者不找生产者直接要数据,而是直接到阻塞队列中要数据。所以阻塞队列就像一个缓冲区,来平衡生产者和消费者的处理能力。这个阻塞队列就是用来队生产者和消费者之间解耦的。
其优点:
1.能解耦
2.支持并发。
3.支持忙闲不均。
在多线程种阻塞队列是一种常见的处理生产者和消费者模型的数据结构。当队列为空时,从队列获取数据的操作会被阻塞(消费者端阻塞),直到队列中放入了元素;反之当队列满时,添加元素到队列的操作会被阻塞(生产者端阻塞),直到队列未满为止。
简单实现
#pragma once
#include
#include
#include
template
class BlockQueue
{
static const int defalutnum = 20;
public:
BlockQueue(int maxcap = defalutnum)
:maxcap_(maxcap)
{
pthread_mutex_init(&mutex_,nullptr);
pthread_cond_init(&c_cond_,nullptr);
pthread_cond_init(&p_cond_,nullptr);
low_water_ = maxcap_ / 3;
high_water = (maxcap_*2) / 3;
}
T pop()
{
pthread_mutex_lock(&mutex_);
if(q_.size() == 0)
{
pthread_cond_wait(&c_cond_,&mutex_);//如果队列已空,则阻塞休眠
}
//到这里要么队列不是空,要么已经休眠被唤醒,反正队列已经不为空了
T out = q_.front();
q_.pop();
if(q_.size() < low_water_)
{
pthread_cond_signal(&p_cond_);//此时低于低水位,唤醒生产者,要生产数据了
}
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T &in)
{
pthread_mutex_lock(&mutex_);
if(q_.size() == maxcap_)
{
pthread_cond_wait(&p_cond_,&mutex_);//同理
}
q_.push(in);
if(q_.size() > high_water)
{
pthread_cond_signal(&c_cond_);
}
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_cond_);
pthread_cond_destroy(&p_cond_);
}
private:
std::queue q_;//共享资源
int maxcap_; //极值
pthread_mutex_t mutex_;
pthread_cond_t c_cond_;//消费者
pthread_cond_t p_cond_;//生产者
int low_water_; //低水位
int high_water; //高水位
};
我们用了模板,也就是说,阻塞队列不仅仅只会放入数据,它也可以放入函数,也就是任务。也可以是对象。
测试代码mian
#include "BlockQueue.hpp"
#include
class Task
{
private:
int a;
int b;
public:
Task(int x, int y):a(x), b(y)
{
}
void run()
{
std::cout << "run task " << a << "+" << b << "=" << a+b << std::endl;
}
~Task()
{
}
};
void* Consumer(void* args)
{
BlockQueue* bq = static_cast*>(args);
while(true)
{
//消费
Task t = bq->pop();
t.run();
sleep(1);
}
}
void* Productor(void* args)
{
BlockQueue* bq = static_cast*>(args);
int x = 10;
int y = 25;
while(true)
{
//生产
Task t(x,y);
bq->push(t);
std::cout << "生产了一个任务" << std::endl;
}
}
int main()
{
BlockQueue* bq = new BlockQueue();
pthread_t c,p;//创建两个线程,一个生产,一个消费。
pthread_create(&c,nullptr,Consumer,bq);
pthread_create(&p,nullptr,Productor,bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete bq;
return 0;
}
运行结果就是先生产了一堆任务,然后每一秒执行了一个,当低于低水位的时候,唤醒生产者,再瞬间生产一堆任务。
并且注意到,队列空了,消费者阻塞,队列满了,生产者阻塞,这个跟管道很像。
如果需要我们简单介绍以下生产消费者模型,我们可以按321原则来进行介绍。
首先是两个角色(2)
在这个模型中有消费者和生产者这两种角色。
然后是一个场所(1)
也就是一个缓冲区,生产者将生产的东西放入这个缓冲区,而消费者从这个缓冲区中进行拿取。
最后是三种关系(3):
生产者和生产者之间是互斥关系(竞争关系)。
消费者和消费者之间是互斥关系。
生产者和消费者之间是 同步且互斥的关系。因为生产和消费时的动作都需要加锁进行保护,所以是互斥,而它们之间有时候又需要互相等待,所以又是同步的关系。
我们之前已知的生产者消费者模型的优点有让生产和消费的动作解耦等等。
但是生产者消费者模型它也是高效的。但是明明加锁和释放锁的操作是影响的性能的,为什么它还是高效的呢?
放任务和拿任务的过程并不高效,因为它本身就是加锁了的,然而,对于生产者,它除了放任务,它在此之前还需要获取数据,处理数据,对于消费者,它在拿到任务后,也需要处理数据,或者加工数据。
所以,这个模型的高效性体现在生产者的非临界区和消费者的非临界区,代码在交叉时,是会并发执行的,就相当于生产者和消费者都是在同时工作的。
我们使用一个简单的运算来充当这个模型中的任务
Task.hpp
#pragma once
#include
#include
std::string opers = "+-*/%";
enum
{
DivZero = 1,
ModZero,
Unknown
};
class Task
{
private:
int data1_;
int data2_;
char oper_;
int result_;
int exitcode_;
public:
Task()
{}
Task(int x, int y, char op) : data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0)
{
}
void run()
{
switch (oper_)
{
case '+':
result_ = data1_ + data2_;
break;
case '-':
result_ = data1_ - data2_;
break;
case '*':
result_ = data1_ * data2_;
break;
case '/':
{
if (data2_ == 0)
exitcode_ = DivZero;
else
result_ = data1_ / data2_;
}
break;
case '%':
{
if (data2_ == 0)
exitcode_ = ModZero;
else
result_ = data1_ % data2_;
}
break;
default:
exitcode_ = Unknown;
break;
}
}
void operator()()
{
run();
}
std::string GetResult()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=";
r += std::to_string(result_);
r += ",exitcode = ";
r += std::to_string(exitcode_);
return r;
}
std::string GetTask()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "= ?";
return r;
}
~Task()
{
}
};
然后再重新设计一个阻塞队列
BlockQueue.hpp
#pragma once
#include
#include
#include
template
class BlockQueue
{
static const int defalutnum = 20;
private:
std::queue q_; // 这是共享资源,q被当作整体使用,q只有一份,加锁,但是共享资源也可以被看做多份
pthread_mutex_t mutex_;
pthread_cond_t c_cond_;
pthread_cond_t p_cond_;
int maxcap_;
public:
BlockQueue(int maxcap = defalutnum) : maxcap_(maxcap)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&c_cond_, nullptr);
pthread_cond_init(&p_cond_, nullptr);
}
T pop()
{
pthread_mutex_lock(&mutex_);
while (q_.size() == 0) // 注意判断临界资源条件是否满足,也是在访问临界资源
{
// 使用循环而不是if是为了避免出现伪唤醒现象而导致程序出错
pthread_cond_wait(&c_cond_, &mutex_);
}
T out = q_.front();
q_.pop();
pthread_cond_signal(&p_cond_);
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T &in)
{
pthread_mutex_lock(&mutex_);
while (q_.size() == maxcap_)
{
// 循环判断防止伪唤醒
pthread_cond_wait(&p_cond_, &mutex_);
}
q_.push(in);
pthread_cond_signal(&c_cond_);
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_cond_);
pthread_cond_destroy(&p_cond_);
}
};
同时要注意生产和消费时,防止伪唤醒的手段。
关于伪唤醒:
当条件满足时,我们才唤醒这个线程,但是当这个线程被唤醒了,但是条件却不满足了,这种情况就是伪唤醒。
如果我们用的if,当出现了线程被唤醒,但是此时条件不满足,而这个线程却继续向下执行时,程序就会出错,因此我们用循环来进行判断,在这里可以避免伪唤醒而引发的问题。
在main函数中,我们模拟了生产者获取数据的动作,和消费处理数据的动作
main.cc
#include "BlockQueue.hpp";
#include "Task.hpp";
#include
#include
void *Consumer(void *args)
{
BlockQueue *bq = static_cast *>(args);
while (true)
{
// 消费
Task t = bq->pop();
t();//处理数据
std::cout << "处理任务: " << t.GetTask() << "运算结果: " << t.GetResult()
<< " thread id: " << pthread_self() << std::endl;
}
}
void *Productor(void *args)
{
int len = opers.size();
BlockQueue *bq = static_cast *>(args);
int x = 10;
int y = 20;
while (true)
{
// 模拟生产者生产数据
int data1 = rand() & 10 + 1;
usleep(20);
int data2 = rand() % 10;
char op = opers[rand() % len];
Task t(data1, data2, op);
// 生产
bq->push(t);
std::cout << "生产了一个任务: " << t.GetResult() << " thread id: "
<< pthread_self() << std::endl;
sleep(1);//此处模拟的是获取数据的动作,及耗费的时间
}
}
int main()
{
srand(time(nullptr));
// BlockQueue 因为是模板,所以可以传其他的数据,或者对象都可以
BlockQueue *bq = new BlockQueue();
pthread_t c[3], p[5]; // 多个生产和消费者
for (int i = 0; i < 3; i++)
{
pthread_create(c + i, nullptr, Consumer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_create(p + i, nullptr, Productor, bq);
}
for (int i = 0; i < 3; i++)
{
pthread_join(c[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(p[i], nullptr);
}
delete bq;
return 0;
}
makefile
BlockQueue:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f BlockQueue
运行结果
这里跟数据结构的环形队列很像。
但是它必须满足三个条件:
1.当生产者和消费者指向同一个位置时:
如果队列为空:只允许生产者移动
如果队列为满:只允许消费者移动
2.消费者不能超过生产者(毕竟不能超前消费)
3.生产者不能绕消费者一圈(人话就是,队列满了生产者也不能覆盖数据,也就是不能动了)
对于如何控制这个环形队列的三个条件,我们可以使用信号量来解决,信号量的本质就是一个计数器,用来计数并控制当前这个共享资源被访问的线程。
所以对单生产单消费者的环形队列,我们不需要锁,因为信号量的申请和释放已经是原子性的了。对于多生产多消费者模型,则需要分别对生产者之间和消费者之间加上锁,以此来保证在这个队列中一次只能由一个生产者和消费者在移动,整个过程需要两把锁。
首先是环形队列的实现
RingQueue.hpp
#pragma once
#include
#include
#include //信号量
#include
const static int defaultcap = 5;
template
class RingQueue{
private:
std::vector ringqueue_;
int cap_;
int c_step_; // 消费者下标
int p_step_; // 生产者下标
sem_t cdata_sem_; // 消费者关注的数据资源
sem_t pspace_sem_; // 生产者关注的空间资源
pthread_mutex_t c_mutex_;
pthread_mutex_t p_mutex_;
private:
void P(sem_t &sem) // 对申请信号量进行了封装,使代码整洁
{
sem_wait(&sem);
}
void V(sem_t &sem) // 释放信号量
{
sem_post(&sem);
}
void Lock(pthread_mutex_t &mutex) // 同理
{
pthread_mutex_lock(&mutex);
}
void Unlock(pthread_mutex_t &mutex)
{
pthread_mutex_unlock(&mutex);
}
public:
RingQueue(int cap = defaultcap) //构造函数里对信号量和锁的初始化
:ringqueue_(cap),cap_(cap),c_step_(0),p_step_(0)
{
sem_init(&cdata_sem_,0,0); //注意消费者和生产者信号量数量的初始化
sem_init(&pspace_sem_,0,cap);
pthread_mutex_init(&c_mutex_,nullptr);
pthread_mutex_init(&p_mutex_,nullptr);
}
void Push(const T &in) //生产
{
P(pspace_sem_); //特别思考一下,这里是先申请信号量再加锁,为什么不是先加锁再申请信号量?
Lock(p_mutex_);
ringqueue_[p_step_] = in;
p_step_++;
p_step_ %= cap_;
Unlock(p_mutex_); //同理
V(cdata_sem_); // 注意这里释放的是消费者的信号量,要理解它们的运作方式
}
void Pop(T *out) //这里我们用的输出型参数来带出返回值,因为对于自定义类型可以不用重写运算符 '='
{
P(cdata_sem_);
Lock(c_mutex_);
*out = ringqueue_[c_step_];
c_step_++;
c_step_ %= cap_;
Unlock(c_mutex_);
V(pspace_sem_);
}
~RingQueue() //最后析构函数中对信号量和锁进行销毁
{
sem_destroy(&cdata_sem_);
sem_destroy(&pspace_sem_);
pthread_mutex_destroy(&c_mutex_);
pthread_mutex_destroy(&p_mutex_);
}
};
特别注意,在Push和Pop那里,我们是先申请信号量再加锁的,因为申请信号量本身就是原子性的,把这个动作放在加锁的前面,对于这些线程来说,申请信号量的动作和申请锁的动作就是并发进行的,在保证安全的情况下提高了代码的运行效率。释放信号量时同理,这也是尽量减少临界区代码的原则。
main.cc
#include
#include
#include
#include
#include "RingQueue.hpp"
#include "Task.hpp"
using namespace std;
struct ThreadData
{
RingQueue *rq;
std::string threadname;
};
void *Productor(void *args)
{
ThreadData *td = static_cast(args);
RingQueue *rq = td->rq;
std::string name = td->threadname;
int len = opers.size();
while(true)
{
// 获取数据
int data1 = rand() % 10 + 1;
usleep(10);
int data2 = rand() % 10;
char op = opers[rand() % len];
Task t(data1,data2,op);
// 生产数据
rq->Push(t);
cout << "生产任务完成: " << t.GetTask() << "who: " << name << endl;
sleep(1);
}
return nullptr;
}
void *Consumer(void *args)
{
ThreadData *td = static_cast(args);
RingQueue *rq = td->rq;
std::string name = td->threadname;
while(true)
{
// 消费数据
Task t;
rq->Pop(&t);
//处理数据
t();
cout << "消费者拿到任务" << "结果: " << t.GetResult() << " who: " << name << endl;
// sleeo(1);
}
return nullptr;
}
int main()
{
srand(time(nullptr) ^ getpid());
RingQueue *rq = new RingQueue(50);
pthread_t c[5],p[3]; //5个消费者,3个生产者
for(int i = 0; i < 3; i++)
{
ThreadData *td = new ThreadData();
td->rq = rq;
td->threadname = "生产者-" + std::to_string(i);
pthread_create(p + i,nullptr,Productor,td);
}
for(int i = 0; i < 5; i++)
{
ThreadData *td = new ThreadData();
td->rq = rq;
td->threadname = "消费者-" + std::to_string(i);
pthread_create(c + i,nullptr,Consumer,td);
}
for(int i = 0; i < 3; i++)
{
pthread_join(p[i],nullptr);
}
for(int i = 0; i < 5; i++)
{
pthread_join(c[i],nullptr);
}
return 0;
}
在main.cc里我们依旧对线程进行了封装,也模拟了获取数据和处理数据的动作。
makefile
RingQueueTest:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f RingQueueTest
其实环形队列这里,比数据结构部分的要简单,在这里我们仅使用信号量就可以判断队列的空满情况。
我们知道有进程池,内存池 。它们的本质都是为了提高效率。
线程池也是如此,当一个已经出现任务请求时,我们再创建线程,然后再执行,这样的话效率就比较慢了。线程池就是先创建一堆线程,当请求来时,直接让这个线程去做就完了。
PthreadPool.hpp
#pragma once
#include
#include
#include
#include
#include
#include
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int defaultnum = 5;
template
class ThreadPool
{
private:
std::vector threads_; // 使用数组管理线程
std::queue tasks_; // 队列里面放任务
pthread_mutex_t mutex_;
pthread_cond_t cond_;
public:
void Lock()
{
pthread_mutex_lock(&mutex_);
}
void Unlock()
{
pthread_mutex_unlock(&mutex_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
void ThreadSleep()
{
pthread_cond_wait(&cond_, &mutex_);
}
bool IsQueueEmpty()
{
return tasks_.empty();
}
std::string GetThreadName(pthread_t tid)
{
for (const auto &ti : threads_)
{
if (ti.tid == tid)
return ti.name;
}
return "没有这个线程";
}
public:
ThreadPool(int num = defaultnum) : threads_(num)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
static void *HandlerTask(void *args) // 特别注意,这个方法是静态的!
{
ThreadPool *tp = static_cast *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
tp->Lock();
while (tp->IsQueueEmpty()) // 同样使用循环来防止伪唤醒的情况
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
t(); // 处理任务放在加锁之后,使处理操作和临界区的代码可以并行进行
std::cout << name << " 运行, "
<< "结果: " << t.GetResult() << std::endl;
}
}
void Start()
{
int num = threads_.size();
for (int i = 0; i < num; i++)
{
threads_[i].name = "线程-" + std::to_string(i);
pthread_create(&threads_[i].tid, nullptr, HandlerTask, this); // 注意这里参数传的使this指针!
}
}
T Pop()
{
T t = tasks_.front(); // 这里不用加锁,因为调用它的地方已经在锁里面了
tasks_.pop();
return t;
}
void Push(const T &t)
{
Lock();
tasks_.push(t);
Wakeup();
Unlock();
}
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
};
在这个线程池里,多个线程通过循环随时待命,如果任务队列里有任务,那么就会执行任务,在这个线程池类内里面,提供的Push接口是给类外用的,方便下达任务,而Pop接口是给类内用的。
因为线程在类内创建,线程执行的函数如果在类内,则这个函数必须是静态的(因为类内的方法都默认有一个隐藏的参数 this指针,再加上线程执行的函数必须要一个 void*类型的参数,这样不匹配调用规则就会报错),另外如果在这个函数内要调用一些封装的函数,也需要this指针,所以在创建线程的时候我们传的参为this。
main.cc
#include
#include
#include "ThreadPool.hpp"
#include "Task.hpp"
int main()
{
ThreadPool *tp = new ThreadPool(5);
tp->Start();
srand(time(nullptr) ^ getpid());
while(true)
{
//获取任务
int x = rand() % 10 + 1;
usleep(10);
int y = rand() % 5;
char op = opers[rand() % opers.size()];
Task t(x,y,op);
tp->Push(t); //放入任务 然后交给线程池处理
std::cout << "主函数已创建任务: " << t.GetTask() << std::endl;
sleep(1);
}
}
在主函数里面,我们用线程池里的Push接口来下达获取好的任务。
makefile
ThreadPool:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f ThreadPool
执行结果
多线程程序的运行效率, 是一个正态分布的结果, 线程数量从1开始增加, 随着线程数量的增加, 程序的运行效率逐渐变高, 直到线程数量达到一个临界值, 当在增加线程数量时, 程序的运行效率会减小(主要是由于频繁线程切换影响线程运行效率) 。
线程池能降低消耗是因为:线程池中更多是对已经创建的线程循环利用,因此节省了新的线程的创建与销毁的时间成本。
线程池还能降低耦合度:线程池模块与任务的产生分离,可以动态的根据性能及任务数量调整线程的数量,提高程序的运行效率。
线程池是一个模块化的处理思想,具有统一管理,资源分配,调整优化,监控的优点。
线程池通过一个线程安全的阻塞任务队列加上一个或一个以上的线程实现,线程池中的线程可以从阻塞队列中获取任务进行任务处理,当线程都处于繁忙状态时可以将任务加入阻塞队列中,等到其它的线程空闲后进行处理。
可以避免大量线程频繁创建或销毁所带来的时间成本,也可以避免在峰值压力下,系统资源耗尽的风险;并且可以统一对线程池中的线程进行管理,调度监控。
1.线程池中线程最大数量:
防止资源耗尽,或线程过多性能降低。
2.线程安全的阻塞队列:
用于任务排队缓冲。
3.线程池中线程的存活时间:
长时间空闲则退出线程节省资源。
4.线程池中阻塞队列的最大节点数量:
防止任务过多,资源耗尽。
我们之前一直使用的是原生线程库,这是Linux系统下的第三方库。
而很多的语言包括C++和java这些,其实都是对这个库再进行了封装,以此来提高语言的可移植性,就比如我们使用c++的库中的线程,它依旧需要链接原生线程库才可以运行。
我们可以尝试简单对原生线程库进行封装。
Thread.hpp
#pragma once
#include
#include
#include
#include
typedef void (*callback_t)(); // 函数指针
static int num = 1;
class Thread
{
private:
pthread_t tid_;
std::string name_;
uint64_t start_timestamp_;
bool isrunning_;
callback_t cb_;
public:
static void *Routine(void *args)
{
Thread *thread = static_cast(args);
thread->Entery(); // 执行函数指针对应的方法
return nullptr;
}
public:
Thread (callback_t cb):tid_(0),name_(""),start_timestamp_(0),isrunning_(false),cb_(cb)
{}
void Run()
{
name_ = "线程-" + std::to_string(num++);
start_timestamp_ = time(nullptr);
isrunning_ = true;
pthread_create(&tid_,nullptr,Routine,this) ;
}
void Jion()
{
pthread_join(tid_,nullptr);
isrunning_ = false;
}
std::string Name()
{
return name_;
}
uint64_t StartTimestamp()
{
return start_timestamp_;
}
bool IsRuning()
{
return isrunning_;
}
void Entery()
{
cb_();
}
~Thread()
{}
};
main.cc
#include
#include
#include
#include "Thread.hpp"
using namespace std;
void Print()
{
printf("哈喽,我是一个封装的线程!!\n");
sleep(1);
}
int main()
{
std::vector threads;
for(int i = 0; i < 5; i++)
{
threads.push_back(Thread(Print));
}
for(auto &t : threads)
{
t.Run();
}
cout << "0 是否启动成功: " << threads[0].IsRuning() << endl;
cout << "0 启动成功时的时间戳: " << threads[0].StartTimestamp() << endl;
cout << "0 的线程名字: " << threads[0].Name() << endl;
for(auto &t : threads)
{
t.Jion();
}
return 0;
}
这样,我们创建一个线程就只是把需要线程执行的函数传入即可,这样线程的使用简洁了很多。
makefile
Thread:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f Thread
我们依旧链接了原生线程库,c++封装原生线程库时也是如此。
执行结果
设计模式模式可以理解为一种模板,是前人的经验总结下来的。比如很久以前的编程,都是各种各样,五花八门,有二进制的,汇编的,C语言等等。但是发展至今,很多语言都是面向对象的,说明面向对象的发展是必然的趋势,也是一种进步。设计模式也是这样发展出来的。
一个类只能创建一个对象,这就是单例模式。比如之前的线程池,我们就只需要创建一个对象就可以了。单例模式的实现方式有两种,一种是饿汉方式,一种是懒汉方式。
当这个进程一启动,就会把这个对象创建好,不管有没有使用。
简单示例
template
class Singleton {
static T data;
public:
static T* GetInstance() {
return &data;
}
};
不过这里要注意的是我们是直接在类中定义了一个静态变量,静态变量也是全局变量的一种,当进程一启动,就会静态变量会跟着创建好,这个在main函数调用前就创好了。
饿汉模式的优点就是设计简单,而且并不会设计线程安全。缺点就是,在服务器这种很大的进程在启动时,需要先花费大量的时间来创建这些静态变量,导致服务器的启动速度变慢。
懒汉方式就是这个对象不会在进程一被创建就跟着被创建,而是等到需要用到这个对象的时候,才会创建。所以懒汉方式的核心思想就是“延迟加载”,从而优化服务器的启动速度。
简单示例
template
class Singleton {
volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance() {
if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提高性能.
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
if (inst == NULL) {
inst = new T();
}
lock.unlock();
}
return inst;
}
};
我们同样是使用静态变量来实现单例模式,不过我们定义的是这个变量的指针,在对外使用的接口函数中,当这个指针为空时,我们才会创建这个对象,然后再返回这个对象的指针。
不过特别需要注意的是,这样的方法是存在线程安全的,它可能会导致创建了多个对象,并且多余的对象又没有指针指向,所以无法释放,导致内存泄漏。
所以我们需要通过加锁来保证它的线程安全,但是这个加锁也是有讲究达到,如果我们就只加了锁然后就不管了,虽然解决了线程安全,但是我们发现,这个对象一旦创建以后,if里的判断就永远为假,但是在判断前却依旧加了一把锁,这就导致了多个线程在这种没有意义的地方串行执行,使性能受损,而解决方案就是在锁前再加一个if判断,这样创建完对象后,也再也不会有线程来申请这把锁了。
懒汉方式的优点就是能优化服务器的启动速度。缺点就是设计相对饿汉方式较为复杂,而且存在线程安全问题,但是可以通过加锁来避免。
#pragma once
#include
#include
#include
#include
#include
#include
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int defaultnum = 5;
template
class ThreadPool
{
private:
std::vector threads_; // 使用数组管理线程
std::queue tasks_; // 队列里面放任务
pthread_mutex_t mutex_;
pthread_cond_t cond_;
static ThreadPool *tp_; // 新增的静态的该对象的指针变量。
static pthread_mutex_t lock_; // 需要锁来保证在懒汉方式下实现的单例模式的线程安全
private:
ThreadPool(int num = defaultnum) : threads_(num) //构造和析构私有,就不能直接在栈或者通过new在堆上创建对象
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
ThreadPool(const ThreadPool &) = delete;
const ThreadPool &operator=(const ThreadPool &) = delete;
public:
void Lock()
{
pthread_mutex_lock(&mutex_);
}
void Unlock()
{
pthread_mutex_unlock(&mutex_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
void ThreadSleep()
{
pthread_cond_wait(&cond_, &mutex_);
}
bool IsQueueEmpty()
{
return tasks_.empty();
}
std::string GetThreadName(pthread_t tid)
{
for (const auto &ti : threads_)
{
if (ti.tid == tid)
return ti.name;
}
return "没有这个线程";
}
public:
static void *HandlerTask(void *args) // 特别注意,这个方法是静态的!
{
ThreadPool *tp = static_cast *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
tp->Lock();
while (tp->IsQueueEmpty()) // 同样使用循环来防止伪唤醒的情况
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
t(); // 处理任务放在加锁之后,使处理操作和临界区的代码可以并行进行
std::cout << name << " 运行, "
<< "结果: " << t.GetResult() << std::endl;
}
}
void Start()
{
int num = threads_.size();
for (int i = 0; i < num; i++)
{
threads_[i].name = "线程-" + std::to_string(i);
pthread_create(&threads_[i].tid, nullptr, HandlerTask, this); // 注意这里参数传的使this指针!
}
}
T Pop()
{
T t = tasks_.front(); // 这里不用加锁,因为调用它的地方已经在锁里面了
tasks_.pop();
return t;
}
void Push(const T &t)
{
Lock();
tasks_.push(t);
Wakeup();
Unlock();
}
};
template //在类外定义
ThreadPool *ThreadPool::tp_ = nullptr;
template
pthread_mutex_t ThreadPool::lock_ = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER;
STL的设计初衷是为了将性能挖掘到极致,一旦加锁就会使性能大打折扣。因此STL的线程默认是不安全的,需要调用者自行保证其线程安全。
首先是unique_ptr,它由于只在当前代码块中生效,因此不涉及线程安全问题。
而shared_ptr,因为多个对象会用到同一个计数器,所以本来会存在线程安全问题,但是标准库中考虑到了这个问题,基于原子操作(CAS)的方式保证了shared_ptr能够高效,也就是原子操作的计数器。
每次读取数据时,总是担心数据会被其他线程所修改,所以会在取得数据前加锁, 这时当其他线程想要访问数据时,会被阻塞挂起。这也是迄今我们一直在用的锁。
每次取数据前,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作
这个锁其实不是很常用,通常在游戏领域可能会用到。这个我们只是简单说说。
一般来说,当一个线程访问的数据已经被上锁时,会挂起等待。但是自旋锁不一定总是会挂起等待,也有可能会一直重复做申请锁的动作,直到申请到锁。自旋锁会根据其他线程执行临界区的时长来决定是挂起,还是一直重复去申请。
为了能更好的理解,我们可以拿古时的通缉令打比方,通缉令这个东西,一旦写好了就放在告示牌上贴着,这时就会来很多人,它们可以一起来看通缉令的内容,当通缉令需要更新时,此时只有一根来更新通缉令,而且在更新完之前,别人也看不了。
所以相比较生产者消费者模型,读写问题也是321原则,不过在关系方面略有不同。
比如1,是例子中的告示牌,也就是一个缓冲区。
2指的是两个角色,是例子中看通缉令和写通缉令的人,也就是生产者和消费者。
3依旧是指三种关系,生产者和生产者是互斥关系,生产者和消费者是同步且互斥的关系,而消费者和消费者确实共享的关系。
所以读写问题唯一与生产者消费者模型不同点在于消费者和消费者之间是共享关系。
读写锁诞生的原因,因为有时候对于一个共享资源,大多时候都是在读取,修改少,而在读的过程中往往伴随着查找,因此耗时较长,给这部分代码加锁,会极大的降低程序的效率,因此就出现了读写锁。
初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t
*restrict attr);
销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
上锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
通过上锁接口我们发现与之前的锁有点不一样,对于读和写上锁的接口不同,但是解锁是一样的。
这是因为,读和写的同步问题,当一个数据正在被读取时,现在有两个线程,一个要修改,一个要读取,为了保证有序,我们分成了读优先和写优先。
在初始化后,可以通过以下这个接口来设置读优先还是写优先
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
关于读写锁的使用,除了接口不同,还有就是在初始化后要设置这个锁的属性之外,用法和之前的锁一样,所以不再演示,我们通过伪代码的方式演示一下读写锁以读优先的原理
以下是以读优先的伪代码,理解rwlock的实现原理
int reader_cuont = 0; //读者计数器,记录当前有多少个读者
mutex_t rlock,wlock; //就是正常的两把锁
对于读者加锁 && 解锁
lock(&rlock);
reader_cuont++;
if(reader_cuont == 1) lock(&wlock);
unlock(&rlock);
//进行读取操作
lock(&rlock)
reader_cuont--;
if(reader_cuont == 0) unlock(&wlock);
unlock(&rlock);
对于写者 加锁 && 解锁
lock(&wlock);
//进行写入操作
unlock(&wlock);
可见,对于读优先时,读者的操作就是对于增减这个计数器的时候会加锁,进行耗时的读取数据操作是没有锁的,另外因为是读优先,所以只要读者不为0,在读这边就会给写加锁,直到读者为0。
而对于写者,就是正常的加锁解锁操作。这就是读写锁以读优先的基本原理,这样会存在写者饥饿问题。
明白了读优先,写优先的原理也自然就懂了,优缺点也是,这就是读写锁。
涉及到了数据的运算,则涉及从内存加载数据到寄存器,在寄存器中运算,将寄存器中数据交还内存的过程因此需要加锁保护的操作中。
原子性,可xu'xing线程是进程内的一个执行分支,线程的执行粒度比进程要细。

在Linux眼中,看到的PCB比传统的进程更加轻量化,所以也叫做轻量化进程。
现在我们对于进程的定义就是一堆执行流+进程地址空间+页表这些+在物理内存中的代码和数据。
所以线程就是 多创建出来的task_struct结构体。或者我们直接把第一个叫做主线程,其他的都叫做新线程。
不同的操作系统对于线程的概念是一样的,但是它们的实现方案可能不一样。
线程创建出来后也需要被OS管理起来,在Windos中,有一套专门管理线程的方案,并把它们放在tcb结构体(thread ctrl block)中进行管理,但是Linux直接复用了进程的数据结构和管理算法,直接用task_struct来模拟线程,因为他们都是一个执行流,不过有主线程和新线程之分。Linux的管理方案耦合度低,并且比较简单,维护成本低,健壮性很强,所以Linux相对与其他操作系统就很强,所以它在服务器上跑,只要不出意外,能跑个两三年都没问题,有些系统就不行(比如Windows)。
在CPU中,只有调度执行流的概念。
线程VS进程
线程:是OS调度的基本单位 。
进程:是承担OS分配资源的基本实体。因为页表,进程地址空间,物理内存中的代码和数据,包括执行流都是资源!
我们之前谈的进程只有一个执行流。其实进程里面可以有n个执行流。
Linux中
线程<=执行流<=进程。其中全等于的情况就是这个进程内只有一个执行流。
Linux中的执行流,叫做轻量级进程。(统一的)
所以释放一个进程就是要把它申请的资源全部释放掉(之前说的包括了执行流这些)。
线程比进程要更加轻量化,原因如下:
1.创建和释放更加轻量化(创建只是创建了一个task_struct结构体)
2.切换更加轻量化。
我们知道执行流在CPU中执行是要被切换的,如果是进程被切换,CPU中有大量的寄存器,那么与之相关的进程地址空间,页表,代码上下文和数据都要被切换,而线程切换页表和进程地址空间都不需要切换,所以线程切换只是在局部进行切换。
但是这个速度还不是决定性因素。首先线程执行本质就是进程在执行,因为线程是进程的一个执行分支。在CPU内部有一块储存硬件,叫做cache。它的存储空间相比内存很小,但是相比CPU很大。在这个进程被执行时,cache内部中缓存着进程的热数据,一个进程运行越久就会越快,因为在cache中命中率会越来越高。
因为线程很多资源是共享的,所以在线程切换时,cache可以保存其中的数据。但是如果整个进程的时间片都完了,要切换一个进程了,那么cache的数据需要重新被加载,也就是数据需要先变冷再变热。所以在同一个进程内的线程进行切换,cache的数据是可以保存而不需要重新加载的。这就是线程效率比进城高的主要原因。
不过这样的话CPU就需要知道换上来的执行流是同一个进程内部的。起始在task_struct的里面有标明身份的标识符,所以就有了主线程和新新线程之说。
线程的优点
1.创建一个线程的代价比创建一个进程的代价要小的多,不过前提是得有这个进程。
2.与进程之间切换相比,线程之间切换需要OS做的事情少得多。
3.线程占用的资源比进程要少得多。
4.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
5.I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
1.性能损失
这个是针对计算密集型的。
2.健壮性降低/缺乏访问控制
因为资源大部分是共享的,所以一个线程在运行时可能会影响另一个线程,比如一个线程里面发生了除零错误或者野指针错误,那么会导致整个进程挂掉。因为线程是进程的一个执行分支,发生异常后进程依旧会收到信号,那么每个线程都会收到并执行对应的方法。
3.编程难度变高。
总结:
1.进程是资源分配的基本单位。
2.线程是调度的基本单位。
3.线程也有自己的一部分数据:
线程ID,栈,一组寄存器(线程的上下文),errno,信号屏蔽字,调度优先级。
其中最重要的就是线程有独立的栈和上下文。
除了数据和代码,线程还共享以下进程资源和环境:
文件描述符表,每种信号的处理方式,当前工作目录,用户id和组id。
重谈地址空间
首先在CPU中有一个CR3寄存器,它是保存了当前执行流的页表的地址。物理内存空间是以4kb来分配的(页框),磁盘中也是(页帧)。
32位的计算机,虚拟地址的大小也就是4字节(32位比特位)。
其中这个32其实是32 = 10 + 10 + 12。这什么意思呢?
首先页表其实并不只是一块的,如果只是简单的只有一块页表,里面直接存虚拟地址向物理地址的转换,那么这个页表会非常的大,整个内存都装不下。
所以这个页表其实是被分成了两级的。
首先一级页表只有2^10个条目(也就是1024个),二级页表也有1024个条目。其中一级页表每个元素的内容就是指向二级页表 的指针。

所以前10个比特位转化成十进制后,就充当了一级页表的下标。找到二级页表后,同样用后续10个比特位转化为十进制后,找到在二级页表中对应的下标的元素。其中二级页表中的元素里存的就是在物理内存地址中,一个页框的起始地址。我们发现,最后的12个比特位2^12 = 4096,刚好就是4kb,那么找到页框的起始地址后,根据偏移量来找到最终的位置!因为页框也刚好是4kb的大小,所以不会有越界的问题。
不过二级页表大部分是不全的。
这就是虚拟地址向物理地址较为具体的转换过程。
不过我们发现,这样只能帮我们找到一个字节。但是在我们的C或者C++等语言里,一个变量的类型可能有4或者8个字节,但是我们依旧可以精准的读取数据,原因就是类型告诉了OS该找多少个字节。而不是去找4个或8个地址。
所以任何变量都只有一个地址。对于结构体和类其实也就是一堆变量(内置类型)的集合体,在代码编译成二进制后就已经没有结构体或类的概念了。但是注意空类的大小是1字节,因为它要充当占位符。
CPU中的CR3寄存器直接指向了页目录,也就是一级页表的起始地址。对任何进程来说,二级目录可以没有,但是一级必须有,因为它是页目录。
其中CPU中还有一个CR2寄存器,这里面放的就是引起缺页中断异常的虚拟地址。
线程目前分配资源,本质就是分配地址空间范围。
在我们编写代码时,代码也是有地址的,并且也是虚拟地址。
线程控制
在Linux内核中没有很明确的线程的概念,只有轻量级进程的概念。所以OS不会提供线程的系统调用,只会提供轻量级进程的调用。
在安装Linux系统的时候,OS在应用层会默认自带一个pthread线程库。这里面对轻量级进程的系统调用进行了封装,为用户提供直接的线程接口。这是一个第三方库。
这个库也叫做原生线程库。很多的语言包括C++和java都对这个原生线程库做出了封装,所以在语言上我们可以直接使用语言的线程的接口来创建使用线程,使用语言的线程库的好处就是使得代码的可移植性变高,但同时对语言的依赖性也会变高。
因为这个是第三方共享库,所以即便系统已经默认自带了这个库,也包含了头文件,但是在编译的选项时,我们依然要指明要使用这个库。

makefile
threadTest:threadTest.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f threadTest
pthread_create()

第一个参数就是输出型参数,创建成功后会返回这个线程的专属编号。其中这个pthread_t也是这个库封装的类型。
第二个参数就是设置我们要创建的线程的属性,不过这个选项我们一般设置为nullptr就可以了。
第三个参数就是函数指针了,就是我们需要这个线程要执行的什么任务,把函数名传上去即可。并且这个函数的格式是有要求的,它的返回值必须是(void*)。
关于为什么要用void*,因为如果我们想让接收返回值时做到与类型无关,就会这样设计,比如malloc的返回值就是void*,需要用户强转。但是不能是void,必须是void*,因为void不能定义变量,而且在重谈地址空间后我们也知道,任何变量只有一个地址,所以我们需要知道这个变量的大小,才能完整的读取这个变量的值。一般32位机器的地址是4字节,64位机器的地址是8字节。不过在C++中出现了模板,就不再使用void*了。
第四个参数就是将来我们需要线程调用这个函数的时候,这个函数所需要的参数。也就是说,创建线程成功,新线程需要回调线程函数的时候,需要参数,这个参数就是传递给线程函数的。没有可以设置为nullptr(NULL)。

如果线程创建成功,返回一个0,错误就会返回错误码。注意这里,它并没有用errno。 它是以返回值告诉我们错误的。
pthread_join()
因为主线程也要起到管理新线程的作用,所以主线程要最后退出,也就是要等待新线程退出后,自己再退出

其中第一个参数就是之前的tid。表示要等待的线程。
第二个参数就是我们接收线程函数的返回值,如果我们不关心返回值,可以填nullptr。
为什么要用这样的方式来接收呢?
因为线程在做它们被分配的任务时,是在内核中执行的,用户无法跟内核中的数据进行交互,于是我们可以通过传入一个输出型的二级指针的方式,将返回值赋值到这个指针解引用后的变量,以此将返回值带回来。
pthread_cancel()

这个函数就是取消这个线程,我们只需要传入这个线程的tid即可。
这个函数的逻辑有点像用kill来杀掉这个进程。
如果用这个函数杀掉了这个线程,在join的时候会得到一个-1。不过这个函数不常见。
需要注意的是,我们之前说过进程可以程序替换,线程可以吗?当然不行,如果替换成功,那么整个进程被替换了,也就没线程什么事了。
在线程运行时,我们可以用 ps -aL来查看当前OS启动的所有轻量级进程

我们发现 PID是一样的,但是有一个LWP会有些微差别,这个LWP起始就是轻量级进程id,我们知道Linux调度的基本单位是线程,也就是按这个LWP来调度的。 系统是按LWP来标识这个线程的,在用户层上是用tid来标识在这个进程里的线程的。
TTY就是终端的意思。
并且我们发现 PID和LWP相等的就是主线程,那么其他的就是新线程。
我们可以用kill命令来杀掉一个新线程


然后我们发现整个进程都退出了。所以也就验证了之前说到过的,信号是给整个进程发的。一个线程出异常了,整个进程都会退出。
简单使用
#include
#include
#include//原生线程库
#include
#include
using namespace std;
void* threadRoutine(void* args)
{
const char* name = (char*)args;
int cnt = 5;
while(true)
{
cout << name << "thread id: " << getpid() << endl;
sleep(1);
if(!cnt--)
return (void*)1;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRoutine,(void*)"thread 1");
cout << "main thread create done, new thread id : " << getpid() << endl;
void* result;
pthread_join(tid,&result);
cout << "main quit ...." << (long long)result << endl;
return 0;
}
如果我们想用C++11的多线程,也需要在编译时加上 -lpthread来指定这个库。
C++11线程库的简单使用
#include//C++11线程库的简单使用
void threadrun()
{
while(true)
{
cout << "C++11 thread" << endl;
sleep(1);
}
}
int main()
{
thread t1(threadrun);
t1.join();
return 0;
}
clone()
回过头来一想,Linux是如何给我们创建轻量级进程的呢?
 fork()底层调用的就是这个函数。
其中第一个参数就是是一个函数指针,它创建好的执行流就是执行的这个函数。
第二个参数就是我们需要给个执行流自定义一个栈。
第三个参数就是创建出来的进程要不要进程资源共享。默认是共享。
这个系统接口被原生线程库封装了。
对于用户层来说,线程的概念是由库来维护的。那么当我们的进程在运行的时候,这个库要加载到内存中,在进程的地址空间上位于共享区的位置。所以为什么说线程的栈是独占的,因为这个栈的空间是由在共享区里开辟的!
注意,库维护的是线程的概念,而不是执行流,task_struct是在内核中由OS维护的,因为OS只提供了轻量化进程的接口,所以线程的属性之类的字段由库来维护。线程库注定了要维护多个线程属性集合。所以库要管理线程,也就要先描述再组织。
所以线程是在OS之上的,也叫做用户级线程

如上,库在共享区里,在共享区里维护着一块一块(这里一块就代表了一个线程)的线程的属性,也可以把这一块 看作tcb。我们发现,tid值就是这个一块的起始地址!也就是说,每一个线程库级别的tcb的起始地址,就是线程的pid!
所以每一个新线程都是用的共享区里面的独立的栈,而主线程则是直接用的地址空间里的栈结构。

简单说下这个函数,就是获取当前线程的tid。
主线程中也可以调用这个函数,只不过大多字段属性没什么用。
线程的同步与互斥
线程的栈结构是独立的

我们在每个线程函数里面创建了一个变量,通过取地址打印发现,它们的地址各不相同。
注意,这里的变量在独立的栈里面,只能说明这个变量是独立的,而不是私有。我们任然有办法可以在其他的线程里访问这个变量。因为这个变量依旧在进程地址空间里面。 比如我们可以直接在全局定义一个指针,然后可以通过if等判断语句来拿到某个具体的线程中的变量的地址,然后在主线程中就可以访问了。
不过这种操作一般是禁止的,因为很不安全。
所以虽然线程有自己独立的栈结构,但是线程与线程之间几乎是没有秘密的,线程的栈上的数据,也可以被其他线程看到并访问。
这个全局的变量也叫做共享资源。
那么一个线程想要一个私有的全局变量也是可以的。编译器提供了一个 __thread,用它来修饰全局变量,就能做到每个线程中都有着对应的这一份全局变量,也就是给每一个线程开辟了一份。

我们发现每个地址都不一样,且看样子不在全局变量区了。 注意这个是编译器提供的。
这种技术也叫做线程的局部存储。线程的局部存储只能修饰内置类型,不能修饰自定义类型!
互斥的原理
首先看一个示例,假设一个电影院售卖一千张票,有四个黄牛来抢票(四个新线程)。代码如下
#include
#include
#include
#include
#include
#include
using namespace std;
#define NUM 4 //四个黄牛
__thread int n = 10; // 定义一个私有的全局变量
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 1000; //一千张票
void* getTicket(void* args)
{
threadData* td = static_cast(args);
const char* name = td->threadname.c_str();
while(true)
{
if(tickets > 0)
{
usleep(1000);
printf("who=%s,get a ticket: %d\n",name,tickets);
tickets--;
}
else
break;
}
printf("%s ...quit\n",name);
cout << &n << endl; //验证这个私有的全局变量的地址
return nullptr;
}
int main()
{
vector tids;
vector thread_datas;
for(int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid,nullptr,getTicket,thread_datas[i - 1]);
tids.push_back(tid);
}
// for(auto i : tids)
// {
// pthread_detach(i);
// }
for(auto i : tids)
{
pthread_join(i,nullptr);
}
for(auto& x: thread_datas)
{
delete x;
}
cout << "main quit" << endl;
return 0;
}
makefile
注意,我们用了一个类来封装我们需要传入线程函数的参数,这样的好处就是使得代码的扩展性变高了,我们可以传入一个字符串当参数,但是将来如果我们还需要增加属性的话就得改很多地方,如果用类来封装的话,直接在类里面增加就可以了。
pthread_detach()
我们发现等待子进程可以阻塞等待或者非阻塞等待,join等待新线程的话是阻塞等待的,如果我们不用join来等待新线程的话,就会造成内存泄漏。
但是如果我们不关心线程的返回值,并且join是一种负担时,我们可以让系统在线程退出后自动释放资源。(比如说一些长期运行的服务器)
int pthread_detach(pthread_t thread)
这个调用也很简单,只要传入需要分离的线程的tid即可。
我们可以在主线程中将某个线程进行分离,也可以线程自己将自己分离。
关于释放一个线程:就是释放PCB,库里面的这个线程的栈和线程的属性等等。页表啥的就不要管了。
注意,如果该线程已经分离了,再用join进行等待时,join就会出错,返回22。
如果主线程退了,其他的进程就算没有执行完,它们也会跟着退。
回到互斥
执行结果如下

我们发现票数怎么可以抢到负数呢?
首先对于一个全局变量,我们在多线程访问的时候,对它进行++或者--。这个操作是否安全呢?
肯定是不安全的。

对于全局变量tickets--,它在汇编是其实是三条语句。(tickets的值是1000)
1.先将tickets的值读入到cpu的寄存器中。
2.在CPU内部进行 -- 操作。
3.再将计算结果写回到内存。
另外我们知道,CPU中的寄存器保存的其实是被调度的线程的上下文!
假设在一个线程内,它重复了以上三个操作10次,但是在最后一次的时候,这个值还保存在寄存器中,要准备写回到内存中时,突然该线程被切换了,那么下次这个线程被换上来时,寄存器中的值应该是990。然后就到了下一个线程换上来继续做--操作,重复了操作900次,并且将值写回到了内存中,那么此时这个变量在内存中的值就是90。然后,切换到之前那个线程了,但是在它的上下文中,它刚好要执行将990的值写回到内存中,那就完蛋了,这个变量的值在内存中又变回了990。造成了数据不一致问题。
所以为什么抢票会抢到0甚至负数,就是因为这几个线程在tickets=1的时候,刚好做完了逻辑运算,进入到了if的代码块中,每一次每一个线程对tickest进行--操作时,都是从内存中读取的,所以就导致了数据不一致问题。
所以对于共享数据的访问,要保证任何时候只允许一个执行流访问。
解决方案就是要加锁。
Linux互斥
相关背景概念
临界资源:被多个执行流共享的资源。
临界区:每个线程的内部,访问临界资源的代码,就是临界区。
互斥:任何时刻,互斥保证这个临界区有且只有一个执行流进入,访问临界资源,通常对临界资源起保护作用。
互斥(Mutual Exclusion)是指在多个线程并发执行的情况下,通过使用互斥锁(Mutex Lock)来保证同时只有一个线程能够访问共享资源。互斥锁是一种同步机制,当一个线程获得了互斥锁后,其他线程必须等待该线程释放锁之后才能继续执行。这样可以避免多个线程同时对共享资源进行读写而导致的数据不一致和竞态条件问题。
原子性:不会被任何调度机制打断,该操作只有两态,要么完成,要么未完成。
互斥量的接口
其中
pthread_mutex_t 也是原先线程库里面封装的类型。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
这样是静态分配一个互斥量(也就是一把锁)。
或者我们也可以先声明这个变量,再动态分配。
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex:要初始化的互斥量
attr:NULL
关于销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
1.使用pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER 这种方式分配互斥量的不需要销毁。
2.不要销毁一个已经加锁的互斥量。
3.已经销毁的互斥量,要确保后续不会有线程来尝试加锁。
互斥量的加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
如果该互斥量处于未锁状态,那么就会上锁成功,并返回0.
如果该互斥量已经被上锁,那么这个线程就会阻塞挂起来等待。
互斥量实现的原理
锁本身也是共享资源!
所以申请和释放锁的操作也必须是原子性的。
如果可以用这个锁来保证我们某个操作的安全性,那么首先这个锁的设计必须是安全的。
我们知道CPU其实只是运算很快,但是并不聪明,它只能明白一些简单的指令。CPU在设计的时候就设计了一套指令集,来识别一些简单的指令,这些指令的操作肯定是原子性的,对于锁的实现,用的是swap或者是exchange指令。因为只有一条指令,所以能保证原子性。
以下是lock和unlock的伪代码

首先关于lock(上锁)
第一条汇编就是将0读取到CPU中的aex寄存器中。
第二条汇编语句就是交换寄存器和在物理内存中的互斥量的值。
最后再判断如果寄存器中的值大于0,那么说明这个锁没有被其他线程上锁,于是该线程就上锁成功,返回0;否则挂起。
unlock就很简单了:
它最核心的就是直接将1放进互斥量的物理内存中。
可重入VS线程安全
线程安全:多个线程并发执行同一段代码时, 不会出现不同的结果。一般对静态变量或全局变量进行操作时,并且没有锁的保护,就会出现线程安全问题。
重入:同一个函数被不同的执行流调用,并且一个执行流还没有执行完,就有另一个执行流调用,就称为重入。一个函数在重入的情况下,运行结果不会出现任何问题,就叫做可重入函数,否则就是不可重入函数。
常见的线程不安全的情况
不保护共享资源的函数。
函数随着被调用,函数状态随之发生改变的函数。
返回静态变量指针的函数。
调用线程不安全的函数。
可重入与线程安全的联系
函数可重入,那么线程肯定是安全的。
函数不可重入,多个线程使用的时候,就有可能引发线程安全问题。
如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全的区别
可重入函数是线程安全函数的一种。
线程安全不一定是可重入的,但是可重入函数一定是线程安全的。
如果对临界资源的访问上锁,则这个函数是线程安全的,但是如果这个重入函数有锁但是因未释放产生死锁问题,则是不可重入的。
用锁改良后的抢票代码
#include
#include
#include
#include
#include
#include
#include "LockGuard.hpp"
using namespace std;
#define NUM 4 //四个黄牛
//__thread int n = 10; // 定义一个私有的全局变量
class threadData
{
public:
threadData(int number)
{
threadname = "thread-" + to_string(number);
}
public:
string threadname;
};
int tickets = 1000; //一千张票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;//全局声明的互斥量(静态声明的)
void* getTicket(void* args)
{
threadData* td = static_cast(args);
const char* name = td->threadname.c_str();
while(true)
{
{
LockGuard lockguard(&lock);//临时的LockGuard的锁,RAII风格。
if(tickets > 0)
{
usleep(1000);
printf("who=%s,get a ticket: %d\n",name,tickets);
tickets--;
}
else
break;
}
}
printf("%s ...quit\n",name);
//cout << &n << endl; //验证这个私有的全局变量的地址
return nullptr;
}
int main()
{
vector tids;
vector thread_datas;
for(int i = 1; i <= NUM; i++)
{
pthread_t tid;
threadData* td = new threadData(i);
thread_datas.push_back(td);
pthread_create(&tid,nullptr,getTicket,thread_datas[i - 1]);
tids.push_back(tid);
}
// for(auto i : tids)
// {
// pthread_detach(i);
// }
for(auto i : tids)
{
pthread_join(i,nullptr);
}
for(auto& x: thread_datas)
{
delete x;
}
cout << "main quit" << endl;
return 0;
}
在这个代码中,同样用了一个类对加锁进行了封装,使用的是RAII的风格的锁。
LockGuard.hpp
#pragma noce
#include
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_;
};
但是运行结果

我们发现几乎都是2号线程在抢票。这也不合理,为什么呢?
这就是线程之间的饥饿问题。
线程之间对于锁的竞争力可能会不同,比如在这个例子中,2号线程对与锁的竞争力太强了,以至于它明明已经将锁还了回去,但是又立马抢到锁了,然后就抢票。
我们可以稍加修改
while(true)
{
{
LockGuard lockguard(&lock);//临时的LockGuard的锁,RAII风格。
if(tickets > 0)
{
usleep(1000);
printf("who=%s,get a ticket: %d\n",name,tickets);
tickets--;
}
else
break;
}
usleep(15);
}
我们通过在每一次抢票完全结束后,休眠一小会的方式,让其他线程得以被切换上来,这样就使得所有线程对于锁的竞争力是均衡的。
这样执行结果就是

看起来就均衡了许多。
总结
加锁的本质:就是用时间来换安全。
加锁的表现:使线程对于临界区代码串行执行。
加锁原则:要尽量保证临界区的代码越少越好。
注意,在线程在加锁的临界区执行代码的时候,也可能会被切走,但是它是带着锁被切走的,结合之前锁的原理很容易理解。所以在线程带锁被切走,这个临界资源任然没有任何人能访问。
常见锁的概念
死锁
死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源,而处于一种永久等待的状态。
造成死锁的四个必要条件:
1.互斥条件:一个资源只能被一个执行流使用。(这是前提条件)
2.请求与保持条件:一个执行流因申请资源而阻塞时,对方获得的资源保持不放。
3.不剥夺条件:一个执行流已获得的资源,在未使用之前,不能强行剥夺。
4.循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
其中第一个条件为前提条件。
第二和第三是原则。
第四是形成死锁的重要条件。
这四个条件全部满足不一定死锁,但是死锁了这四个条件一定全部满足。
如何避免死锁
其实死锁大多数也因为程序猿的编码问题导致的,对于如何避免有以下:
1. 破坏死锁的四个必要条件。
2.加锁顺序一致。
3.避免锁未释放的场景。
4.资源一次性分配。
关于避免死锁的算法有:
1.银行家算法:
银行家算法的思想是为了避免出现“环路等待”条件,银行家算法的思想在于将系统运行分为两种状态:安全/非安全,有可能出现风险的都属于非安全。处于不安全状态只是表示有风险,不代表一定发生,所以银行家算法是避免出现死锁的一种算法(并非预防的方法),也就是避免策略。
2.死锁检测算法。
处理死锁的方法
鸵鸟策略 对可能出现的问题采取无视态度,前提是出现概率很低。
预防策略 破坏死锁产生的必要条件。
避免策略 银行家算法,分配资源前进行风险判断,避免风险的发生。
检测与解除死锁 分配资源时不采取措施,但是必须提供死锁的检测与解除手段。
Linux线程同步
条件变量:当一个线程互斥的访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了,比如一个线程要到一个队列里面领取任务,但是队列是空的,它只能等到其他线程添加任务到这个队列里面,它才能领取并执行任务,这种时候就需要用到条件变量。
同步:在保证数据安全的情况下, 让线程能按照特定的顺序来访问临界资源,从而避免饥饿问题,叫做同步。
同步(Synchronization)是指在多个线程之间协调和控制执行顺序,以确保线程之间的操作按照特定的顺序进行。常见的同步机制包括信号量(Semaphore)、条件变量(Condition Variable)和屏障(Barrier)等。通过使用这些同步机制,可以实现线程之间的通信和协作,确保线程按照预定的顺序进行执行。

比如说,一个线程刚刚执行完一个任务,它就需要唤醒下一个需要执行任务的线程,然后将自己放到队尾进行排队。中间这个就是条件变量(图中的结构体)。
条件变量必须依赖锁的使用,不然也就没什么意义了。
条件变量相关函数
初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)

当然条件变量也可以全局的初始化,这样也就不需要销毁了。
等待条件满足
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
第二个参数就是传入一把锁。
唤醒等待的函数
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
简单使用:
#
include
#include
#include
const int NUM = 5;
int cnt = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 全局初始化的条件变量
void *Count(void *args)
{
pthread_detach(pthread_self());
uint64_t number = (uint64_t)args;
std::cout << "pthread: " << number << " create success" << std::endl;
while (true)
{
pthread_mutex_lock(&mutex);
// 注意,我们是上锁后再进行的条件等待。
// 其目的就是为了判断临界资源是否就绪,因为判断也是访问临界资源,
// 所以需要先上锁再判断!如果资源就绪了就执行,没有就绪就释放锁,并排队。
pthread_cond_wait(&cond, &mutex);
std::cout << "pthread: " << number << " , cnt: " << ++cnt << std::endl;
pthread_mutex_unlock(&mutex);
}
}
int main()
{
for(uint64_t i = 0; i < NUM; i++)
{
pthread_t tid;
pthread_create(&tid,nullptr,Count,(void*)i);
usleep(1000);
}
sleep(3);
std::cout << "main thread ctrl begin: " << std::endl;
while(true)
{
sleep(1);
pthread_cond_broadcast(&cond);
std::cout << "signal one thread..." << std::endl;
}
return 0;
}
makefile
test_cond:test_cond.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f test_cond

我们发现加入了同步之后,解决了饥饿问题。
pthread_cond_wait让线程等待的时候,会自动释放锁!如果资源不就绪,那么就等待休眠。
用pthread_cond_wait让线程等待以后,还需要今后将这个线程唤醒,才能继续执行。我们是在主线程中唤醒的。
另外如果我们将sleep(1)改为usleep(10)往小了改,那么就会出现以下结果

我们发现在显示器上打印的信息比较混乱,这是因为在Linux下,显示器也是文件,在被多个线程访问时,它也是共享资源。
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
这两个唤醒函数的区别就是,第一个是唤醒在这个条件变量中的所有线程,第二个则是唤醒一个线程,默认是第一个。我们用的是第一个,全部唤醒。
生产者消费者模型
生产者消费者模型就是通过一个容器来解决生产者和消费者之间的强耦合问题。 生产者和消费者之间不直接通讯,而是通过阻塞队列进行通讯。所以生产者生产完数据后,不用等消费者处理,直接扔进阻塞队列中,消费者不找生产者直接要数据,而是直接到阻塞队列中要数据。所以阻塞队列就像一个缓冲区,来平衡生产者和消费者的处理能力。这个阻塞队列就是用来队生产者和消费者之间解耦的。

其优点:
1.能解耦
2.支持并发。
3.支持忙闲不均。
阻塞队列
在多线程种阻塞队列是一种常见的处理生产者和消费者模型的数据结构。当队列为空时,从队列获取数据的操作会被阻塞(消费者端阻塞),直到队列中放入了元素;反之当队列满时,添加元素到队列的操作会被阻塞(生产者端阻塞),直到队列未满为止。
简单实现
#pragma once
#include
#include
#include
template
class BlockQueue
{
static const int defalutnum = 20;
public:
BlockQueue(int maxcap = defalutnum)
:maxcap_(maxcap)
{
pthread_mutex_init(&mutex_,nullptr);
pthread_cond_init(&c_cond_,nullptr);
pthread_cond_init(&p_cond_,nullptr);
low_water_ = maxcap_ / 3;
high_water = (maxcap_*2) / 3;
}
T pop()
{
pthread_mutex_lock(&mutex_);
if(q_.size() == 0)
{
pthread_cond_wait(&c_cond_,&mutex_);//如果队列已空,则阻塞休眠
}
//到这里要么队列不是空,要么已经休眠被唤醒,反正队列已经不为空了
T out = q_.front();
q_.pop();
if(q_.size() < low_water_)
{
pthread_cond_signal(&p_cond_);//此时低于低水位,唤醒生产者,要生产数据了
}
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T &in)
{
pthread_mutex_lock(&mutex_);
if(q_.size() == maxcap_)
{
pthread_cond_wait(&p_cond_,&mutex_);//同理
}
q_.push(in);
if(q_.size() > high_water)
{
pthread_cond_signal(&c_cond_);
}
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_cond_);
pthread_cond_destroy(&p_cond_);
}
private:
std::queue q_;//共享资源
int maxcap_; //极值
pthread_mutex_t mutex_;
pthread_cond_t c_cond_;//消费者
pthread_cond_t p_cond_;//生产者
int low_water_; //低水位
int high_water; //高水位
};
我们用了模板,也就是说,阻塞队列不仅仅只会放入数据,它也可以放入函数,也就是任务。也可以是对象。
测试代码mian
#include "BlockQueue.hpp"
#include
class Task
{
private:
int a;
int b;
public:
Task(int x, int y):a(x), b(y)
{
}
void run()
{
std::cout << "run task " << a << "+" << b << "=" << a+b << std::endl;
}
~Task()
{
}
};
void* Consumer(void* args)
{
BlockQueue* bq = static_cast*>(args);
while(true)
{
//消费
Task t = bq->pop();
t.run();
sleep(1);
}
}
void* Productor(void* args)
{
BlockQueue* bq = static_cast*>(args);
int x = 10;
int y = 25;
while(true)
{
//生产
Task t(x,y);
bq->push(t);
std::cout << "生产了一个任务" << std::endl;
}
}
int main()
{
BlockQueue* bq = new BlockQueue();
pthread_t c,p;//创建两个线程,一个生产,一个消费。
pthread_create(&c,nullptr,Consumer,bq);
pthread_create(&p,nullptr,Productor,bq);
pthread_join(c,nullptr);
pthread_join(p,nullptr);
delete bq;
return 0;
}

运行结果就是先生产了一堆任务,然后每一秒执行了一个,当低于低水位的时候,唤醒生产者,再瞬间生产一堆任务。
并且注意到,队列空了,消费者阻塞,队列满了,生产者阻塞,这个跟管道很像。
生产者消费者模型的321原则
如果需要我们简单介绍以下生产消费者模型,我们可以按321原则来进行介绍。
首先是两个角色(2)
在这个模型中有消费者和生产者这两种角色。
然后是一个场所(1)
也就是一个缓冲区,生产者将生产的东西放入这个缓冲区,而消费者从这个缓冲区中进行拿取。
最后是三种关系(3):
生产者和生产者之间是互斥关系(竞争关系)。
消费者和消费者之间是互斥关系。
生产者和消费者之间是 同步且互斥的关系。因为生产和消费时的动作都需要加锁进行保护,所以是互斥,而它们之间有时候又需要互相等待,所以又是同步的关系。
再谈这个模型的优点
我们之前已知的生产者消费者模型的优点有让生产和消费的动作解耦等等。
但是生产者消费者模型它也是高效的。但是明明加锁和释放锁的操作是影响的性能的,为什么它还是高效的呢?

放任务和拿任务的过程并不高效,因为它本身就是加锁了的,然而,对于生产者,它除了放任务,它在此之前还需要获取数据,处理数据,对于消费者,它在拿到任务后,也需要处理数据,或者加工数据。
所以,这个模型的高效性体现在生产者的非临界区和消费者的非临界区,代码在交叉时,是会并发执行的,就相当于生产者和消费者都是在同时工作的。
基于生产者消费者模型的实践
我们使用一个简单的运算来充当这个模型中的任务
Task.hpp
#pragma once
#include
#include
std::string opers = "+-*/%";
enum
{
DivZero = 1,
ModZero,
Unknown
};
class Task
{
private:
int data1_;
int data2_;
char oper_;
int result_;
int exitcode_;
public:
Task()
{}
Task(int x, int y, char op) : data1_(x), data2_(y), oper_(op), result_(0), exitcode_(0)
{
}
void run()
{
switch (oper_)
{
case '+':
result_ = data1_ + data2_;
break;
case '-':
result_ = data1_ - data2_;
break;
case '*':
result_ = data1_ * data2_;
break;
case '/':
{
if (data2_ == 0)
exitcode_ = DivZero;
else
result_ = data1_ / data2_;
}
break;
case '%':
{
if (data2_ == 0)
exitcode_ = ModZero;
else
result_ = data1_ % data2_;
}
break;
default:
exitcode_ = Unknown;
break;
}
}
void operator()()
{
run();
}
std::string GetResult()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "=";
r += std::to_string(result_);
r += ",exitcode = ";
r += std::to_string(exitcode_);
return r;
}
std::string GetTask()
{
std::string r = std::to_string(data1_);
r += oper_;
r += std::to_string(data2_);
r += "= ?";
return r;
}
~Task()
{
}
};
然后再重新设计一个阻塞队列
BlockQueue.hpp
#pragma once
#include
#include
#include
template
class BlockQueue
{
static const int defalutnum = 20;
private:
std::queue q_; // 这是共享资源,q被当作整体使用,q只有一份,加锁,但是共享资源也可以被看做多份
pthread_mutex_t mutex_;
pthread_cond_t c_cond_;
pthread_cond_t p_cond_;
int maxcap_;
public:
BlockQueue(int maxcap = defalutnum) : maxcap_(maxcap)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&c_cond_, nullptr);
pthread_cond_init(&p_cond_, nullptr);
}
T pop()
{
pthread_mutex_lock(&mutex_);
while (q_.size() == 0) // 注意判断临界资源条件是否满足,也是在访问临界资源
{
// 使用循环而不是if是为了避免出现伪唤醒现象而导致程序出错
pthread_cond_wait(&c_cond_, &mutex_);
}
T out = q_.front();
q_.pop();
pthread_cond_signal(&p_cond_);
pthread_mutex_unlock(&mutex_);
return out;
}
void push(const T &in)
{
pthread_mutex_lock(&mutex_);
while (q_.size() == maxcap_)
{
// 循环判断防止伪唤醒
pthread_cond_wait(&p_cond_, &mutex_);
}
q_.push(in);
pthread_cond_signal(&c_cond_);
pthread_mutex_unlock(&mutex_);
}
~BlockQueue()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&c_cond_);
pthread_cond_destroy(&p_cond_);
}
};
同时要注意生产和消费时,防止伪唤醒的手段。
关于伪唤醒:
当条件满足时,我们才唤醒这个线程,但是当这个线程被唤醒了,但是条件却不满足了,这种情况就是伪唤醒。
如果我们用的if,当出现了线程被唤醒,但是此时条件不满足,而这个线程却继续向下执行时,程序就会出错,因此我们用循环来进行判断,在这里可以避免伪唤醒而引发的问题。
在main函数中,我们模拟了生产者获取数据的动作,和消费处理数据的动作
main.cc
#include "BlockQueue.hpp";
#include "Task.hpp";
#include
#include
void *Consumer(void *args)
{
BlockQueue *bq = static_cast *>(args);
while (true)
{
// 消费
Task t = bq->pop();
t();//处理数据
std::cout << "处理任务: " << t.GetTask() << "运算结果: " << t.GetResult()
<< " thread id: " << pthread_self() << std::endl;
}
}
void *Productor(void *args)
{
int len = opers.size();
BlockQueue *bq = static_cast *>(args);
int x = 10;
int y = 20;
while (true)
{
// 模拟生产者生产数据
int data1 = rand() & 10 + 1;
usleep(20);
int data2 = rand() % 10;
char op = opers[rand() % len];
Task t(data1, data2, op);
// 生产
bq->push(t);
std::cout << "生产了一个任务: " << t.GetResult() << " thread id: "
<< pthread_self() << std::endl;
sleep(1);//此处模拟的是获取数据的动作,及耗费的时间
}
}
int main()
{
srand(time(nullptr));
// BlockQueue 因为是模板,所以可以传其他的数据,或者对象都可以
BlockQueue *bq = new BlockQueue();
pthread_t c[3], p[5]; // 多个生产和消费者
for (int i = 0; i < 3; i++)
{
pthread_create(c + i, nullptr, Consumer, bq);
}
for (int i = 0; i < 5; i++)
{
pthread_create(p + i, nullptr, Productor, bq);
}
for (int i = 0; i < 3; i++)
{
pthread_join(c[i], nullptr);
}
for (int i = 0; i < 5; i++)
{
pthread_join(p[i], nullptr);
}
delete bq;
return 0;
}
makefile
BlockQueue:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f BlockQueue
运行结果

基于环形队列的生产者消费者模型

这里跟数据结构的环形队列很像。
但是它必须满足三个条件:
1.当生产者和消费者指向同一个位置时:
如果队列为空:只允许生产者移动
如果队列为满:只允许消费者移动
2.消费者不能超过生产者(毕竟不能超前消费)
3.生产者不能绕消费者一圈(人话就是,队列满了生产者也不能覆盖数据,也就是不能动了)
模型的简单代码实现
对于如何控制这个环形队列的三个条件,我们可以使用信号量来解决,信号量的本质就是一个计数器,用来计数并控制当前这个共享资源被访问的线程。
所以对单生产单消费者的环形队列,我们不需要锁,因为信号量的申请和释放已经是原子性的了。对于多生产多消费者模型,则需要分别对生产者之间和消费者之间加上锁,以此来保证在这个队列中一次只能由一个生产者和消费者在移动,整个过程需要两把锁。
首先是环形队列的实现
RingQueue.hpp
#pragma once
#include
#include
#include //信号量
#include
const static int defaultcap = 5;
template
class RingQueue{
private:
std::vector ringqueue_;
int cap_;
int c_step_; // 消费者下标
int p_step_; // 生产者下标
sem_t cdata_sem_; // 消费者关注的数据资源
sem_t pspace_sem_; // 生产者关注的空间资源
pthread_mutex_t c_mutex_;
pthread_mutex_t p_mutex_;
private:
void P(sem_t &sem) // 对申请信号量进行了封装,使代码整洁
{
sem_wait(&sem);
}
void V(sem_t &sem) // 释放信号量
{
sem_post(&sem);
}
void Lock(pthread_mutex_t &mutex) // 同理
{
pthread_mutex_lock(&mutex);
}
void Unlock(pthread_mutex_t &mutex)
{
pthread_mutex_unlock(&mutex);
}
public:
RingQueue(int cap = defaultcap) //构造函数里对信号量和锁的初始化
:ringqueue_(cap),cap_(cap),c_step_(0),p_step_(0)
{
sem_init(&cdata_sem_,0,0); //注意消费者和生产者信号量数量的初始化
sem_init(&pspace_sem_,0,cap);
pthread_mutex_init(&c_mutex_,nullptr);
pthread_mutex_init(&p_mutex_,nullptr);
}
void Push(const T &in) //生产
{
P(pspace_sem_); //特别思考一下,这里是先申请信号量再加锁,为什么不是先加锁再申请信号量?
Lock(p_mutex_);
ringqueue_[p_step_] = in;
p_step_++;
p_step_ %= cap_;
Unlock(p_mutex_); //同理
V(cdata_sem_); // 注意这里释放的是消费者的信号量,要理解它们的运作方式
}
void Pop(T *out) //这里我们用的输出型参数来带出返回值,因为对于自定义类型可以不用重写运算符 '='
{
P(cdata_sem_);
Lock(c_mutex_);
*out = ringqueue_[c_step_];
c_step_++;
c_step_ %= cap_;
Unlock(c_mutex_);
V(pspace_sem_);
}
~RingQueue() //最后析构函数中对信号量和锁进行销毁
{
sem_destroy(&cdata_sem_);
sem_destroy(&pspace_sem_);
pthread_mutex_destroy(&c_mutex_);
pthread_mutex_destroy(&p_mutex_);
}
};
特别注意,在Push和Pop那里,我们是先申请信号量再加锁的,因为申请信号量本身就是原子性的,把这个动作放在加锁的前面,对于这些线程来说,申请信号量的动作和申请锁的动作就是并发进行的,在保证安全的情况下提高了代码的运行效率。释放信号量时同理,这也是尽量减少临界区代码的原则。
main.cc
#include
#include
#include
#include
#include "RingQueue.hpp"
#include "Task.hpp"
using namespace std;
struct ThreadData
{
RingQueue *rq;
std::string threadname;
};
void *Productor(void *args)
{
ThreadData *td = static_cast(args);
RingQueue *rq = td->rq;
std::string name = td->threadname;
int len = opers.size();
while(true)
{
// 获取数据
int data1 = rand() % 10 + 1;
usleep(10);
int data2 = rand() % 10;
char op = opers[rand() % len];
Task t(data1,data2,op);
// 生产数据
rq->Push(t);
cout << "生产任务完成: " << t.GetTask() << "who: " << name << endl;
sleep(1);
}
return nullptr;
}
void *Consumer(void *args)
{
ThreadData *td = static_cast(args);
RingQueue *rq = td->rq;
std::string name = td->threadname;
while(true)
{
// 消费数据
Task t;
rq->Pop(&t);
//处理数据
t();
cout << "消费者拿到任务" << "结果: " << t.GetResult() << " who: " << name << endl;
// sleeo(1);
}
return nullptr;
}
int main()
{
srand(time(nullptr) ^ getpid());
RingQueue *rq = new RingQueue(50);
pthread_t c[5],p[3]; //5个消费者,3个生产者
for(int i = 0; i < 3; i++)
{
ThreadData *td = new ThreadData();
td->rq = rq;
td->threadname = "生产者-" + std::to_string(i);
pthread_create(p + i,nullptr,Productor,td);
}
for(int i = 0; i < 5; i++)
{
ThreadData *td = new ThreadData();
td->rq = rq;
td->threadname = "消费者-" + std::to_string(i);
pthread_create(c + i,nullptr,Consumer,td);
}
for(int i = 0; i < 3; i++)
{
pthread_join(p[i],nullptr);
}
for(int i = 0; i < 5; i++)
{
pthread_join(c[i],nullptr);
}
return 0;
}
在main.cc里我们依旧对线程进行了封装,也模拟了获取数据和处理数据的动作。
makefile
RingQueueTest:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
rm -f RingQueueTest
其实环形队列这里,比数据结构部分的要简单,在这里我们仅使用信号量就可以判断队列的空满情况。
线程池化
代码
我们知道有进程池,内存池 。它们的本质都是为了提高效率。
线程池也是如此,当一个已经出现任务请求时,我们再创建线程,然后再执行,这样的话效率就比较慢了。线程池就是先创建一堆线程,当请求来时,直接让这个线程去做就完了。
PthreadPool.hpp
#pragma once
#include
#include
#include
#include
#include
#include
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int defaultnum = 5;
template
class ThreadPool
{
private:
std::vector threads_; // 使用数组管理线程
std::queue tasks_; // 队列里面放任务
pthread_mutex_t mutex_;
pthread_cond_t cond_;
public:
void Lock()
{
pthread_mutex_lock(&mutex_);
}
void Unlock()
{
pthread_mutex_unlock(&mutex_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
void ThreadSleep()
{
pthread_cond_wait(&cond_, &mutex_);
}
bool IsQueueEmpty()
{
return tasks_.empty();
}
std::string GetThreadName(pthread_t tid)
{
for (const auto &ti : threads_)
{
if (ti.tid == tid)
return ti.name;
}
return "没有这个线程";
}
public:
ThreadPool(int num = defaultnum) : threads_(num)
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
static void *HandlerTask(void *args) // 特别注意,这个方法是静态的!
{
ThreadPool *tp = static_cast *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
tp->Lock();
while (tp->IsQueueEmpty()) // 同样使用循环来防止伪唤醒的情况
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
t(); // 处理任务放在加锁之后,使处理操作和临界区的代码可以并行进行
std::cout << name << " 运行, "
<< "结果: " << t.GetResult() << std::endl;
}
}
void Start()
{
int num = threads_.size();
for (int i = 0; i < num; i++)
{
threads_[i].name = "线程-" + std::to_string(i);
pthread_create(&threads_[i].tid, nullptr, HandlerTask, this); // 注意这里参数传的使this指针!
}
}
T Pop()
{
T t = tasks_.front(); // 这里不用加锁,因为调用它的地方已经在锁里面了
tasks_.pop();
return t;
}
void Push(const T &t)
{
Lock();
tasks_.push(t);
Wakeup();
Unlock();
}
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
};
在这个线程池里,多个线程通过循环随时待命,如果任务队列里有任务,那么就会执行任务,在这个线程池类内里面,提供的Push接口是给类外用的,方便下达任务,而Pop接口是给类内用的。
因为线程在类内创建,线程执行的函数如果在类内,则这个函数必须是静态的(因为类内的方法都默认有一个隐藏的参数 this指针,再加上线程执行的函数必须要一个 void*类型的参数,这样不匹配调用规则就会报错),另外如果在这个函数内要调用一些封装的函数,也需要this指针,所以在创建线程的时候我们传的参为this。
main.cc
#include
#include
#include "ThreadPool.hpp"
#include "Task.hpp"
int main()
{
ThreadPool *tp = new ThreadPool(5);
tp->Start();
srand(time(nullptr) ^ getpid());
while(true)
{
//获取任务
int x = rand() % 10 + 1;
usleep(10);
int y = rand() % 5;
char op = opers[rand() % opers.size()];
Task t(x,y,op);
tp->Push(t); //放入任务 然后交给线程池处理
std::cout << "主函数已创建任务: " << t.GetTask() << std::endl;
sleep(1);
}
}
在主函数里面,我们用线程池里的Push接口来下达获取好的任务。
makefile
ThreadPool:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f ThreadPool
执行结果

关于线程池
多线程程序的运行效率, 是一个正态分布的结果, 线程数量从1开始增加, 随着线程数量的增加, 程序的运行效率逐渐变高, 直到线程数量达到一个临界值, 当在增加线程数量时, 程序的运行效率会减小(主要是由于频繁线程切换影响线程运行效率) 。
线程池能降低消耗是因为:线程池中更多是对已经创建的线程循环利用,因此节省了新的线程的创建与销毁的时间成本。
线程池还能降低耦合度:线程池模块与任务的产生分离,可以动态的根据性能及任务数量调整线程的数量,提高程序的运行效率。
线程池是一个模块化的处理思想,具有统一管理,资源分配,调整优化,监控的优点。
简述线程池的实现原理和作用?
线程池通过一个线程安全的阻塞任务队列加上一个或一个以上的线程实现,线程池中的线程可以从阻塞队列中获取任务进行任务处理,当线程都处于繁忙状态时可以将任务加入阻塞队列中,等到其它的线程空闲后进行处理。
可以避免大量线程频繁创建或销毁所带来的时间成本,也可以避免在峰值压力下,系统资源耗尽的风险;并且可以统一对线程池中的线程进行管理,调度监控。
线程池的关键参数
1.线程池中线程最大数量:
防止资源耗尽,或线程过多性能降低。
2.线程安全的阻塞队列:
用于任务排队缓冲。
3.线程池中线程的存活时间:
长时间空闲则退出线程节省资源。
4.线程池中阻塞队列的最大节点数量:
防止任务过多,资源耗尽。
封装线程库
我们之前一直使用的是原生线程库,这是Linux系统下的第三方库。
而很多的语言包括C++和java这些,其实都是对这个库再进行了封装,以此来提高语言的可移植性,就比如我们使用c++的库中的线程,它依旧需要链接原生线程库才可以运行。
我们可以尝试简单对原生线程库进行封装。
Thread.hpp
#pragma once
#include
#include
#include
#include
typedef void (*callback_t)(); // 函数指针
static int num = 1;
class Thread
{
private:
pthread_t tid_;
std::string name_;
uint64_t start_timestamp_;
bool isrunning_;
callback_t cb_;
public:
static void *Routine(void *args)
{
Thread *thread = static_cast(args);
thread->Entery(); // 执行函数指针对应的方法
return nullptr;
}
public:
Thread (callback_t cb):tid_(0),name_(""),start_timestamp_(0),isrunning_(false),cb_(cb)
{}
void Run()
{
name_ = "线程-" + std::to_string(num++);
start_timestamp_ = time(nullptr);
isrunning_ = true;
pthread_create(&tid_,nullptr,Routine,this) ;
}
void Jion()
{
pthread_join(tid_,nullptr);
isrunning_ = false;
}
std::string Name()
{
return name_;
}
uint64_t StartTimestamp()
{
return start_timestamp_;
}
bool IsRuning()
{
return isrunning_;
}
void Entery()
{
cb_();
}
~Thread()
{}
};
main.cc
#include
#include
#include
#include "Thread.hpp"
using namespace std;
void Print()
{
printf("哈喽,我是一个封装的线程!!\n");
sleep(1);
}
int main()
{
std::vector threads;
for(int i = 0; i < 5; i++)
{
threads.push_back(Thread(Print));
}
for(auto &t : threads)
{
t.Run();
}
cout << "0 是否启动成功: " << threads[0].IsRuning() << endl;
cout << "0 启动成功时的时间戳: " << threads[0].StartTimestamp() << endl;
cout << "0 的线程名字: " << threads[0].Name() << endl;
for(auto &t : threads)
{
t.Jion();
}
return 0;
}
这样,我们创建一个线程就只是把需要线程执行的函数传入即可,这样线程的使用简洁了很多。
makefile
Thread:main.cc
g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
rm -f Thread
我们依旧链接了原生线程库,c++封装原生线程库时也是如此。
执行结果

设计模式
设计模式模式可以理解为一种模板,是前人的经验总结下来的。比如很久以前的编程,都是各种各样,五花八门,有二进制的,汇编的,C语言等等。但是发展至今,很多语言都是面向对象的,说明面向对象的发展是必然的趋势,也是一种进步。设计模式也是这样发展出来的。
单例模式
一个类只能创建一个对象,这就是单例模式。比如之前的线程池,我们就只需要创建一个对象就可以了。单例模式的实现方式有两种,一种是饿汉方式,一种是懒汉方式。
饿汉方式
当这个进程一启动,就会把这个对象创建好,不管有没有使用。
简单示例
template
class Singleton {
static T data;
public:
static T* GetInstance() {
return &data;
}
};
不过这里要注意的是我们是直接在类中定义了一个静态变量,静态变量也是全局变量的一种,当进程一启动,就会静态变量会跟着创建好,这个在main函数调用前就创好了。
饿汉模式的优点就是设计简单,而且并不会设计线程安全。缺点就是,在服务器这种很大的进程在启动时,需要先花费大量的时间来创建这些静态变量,导致服务器的启动速度变慢。
懒汉方式
懒汉方式就是这个对象不会在进程一被创建就跟着被创建,而是等到需要用到这个对象的时候,才会创建。所以懒汉方式的核心思想就是“延迟加载”,从而优化服务器的启动速度。
简单示例
template
class Singleton {
volatile static T* inst; // 需要设置 volatile 关键字, 否则可能被编译器优化.
static std::mutex lock;
public:
static T* GetInstance() {
if (inst == NULL) { // 双重判定空指针, 降低锁冲突的概率, 提高性能.
lock.lock(); // 使用互斥锁, 保证多线程情况下也只调用一次 new.
if (inst == NULL) {
inst = new T();
}
lock.unlock();
}
return inst;
}
};
我们同样是使用静态变量来实现单例模式,不过我们定义的是这个变量的指针,在对外使用的接口函数中,当这个指针为空时,我们才会创建这个对象,然后再返回这个对象的指针。
不过特别需要注意的是,这样的方法是存在线程安全的,它可能会导致创建了多个对象,并且多余的对象又没有指针指向,所以无法释放,导致内存泄漏。
所以我们需要通过加锁来保证它的线程安全,但是这个加锁也是有讲究达到,如果我们就只加了锁然后就不管了,虽然解决了线程安全,但是我们发现,这个对象一旦创建以后,if里的判断就永远为假,但是在判断前却依旧加了一把锁,这就导致了多个线程在这种没有意义的地方串行执行,使性能受损,而解决方案就是在锁前再加一个if判断,这样创建完对象后,也再也不会有线程来申请这把锁了。
懒汉方式的优点就是能优化服务器的启动速度。缺点就是设计相对饿汉方式较为复杂,而且存在线程安全问题,但是可以通过加锁来避免。
基于单例模式的线程池(懒汉模式)
#pragma once
#include
#include
#include
#include
#include
#include
struct ThreadInfo
{
pthread_t tid;
std::string name;
};
static const int defaultnum = 5;
template
class ThreadPool
{
private:
std::vector threads_; // 使用数组管理线程
std::queue tasks_; // 队列里面放任务
pthread_mutex_t mutex_;
pthread_cond_t cond_;
static ThreadPool *tp_; // 新增的静态的该对象的指针变量。
static pthread_mutex_t lock_; // 需要锁来保证在懒汉方式下实现的单例模式的线程安全
private:
ThreadPool(int num = defaultnum) : threads_(num) //构造和析构私有,就不能直接在栈或者通过new在堆上创建对象
{
pthread_mutex_init(&mutex_, nullptr);
pthread_cond_init(&cond_, nullptr);
}
~ThreadPool()
{
pthread_mutex_destroy(&mutex_);
pthread_cond_destroy(&cond_);
}
ThreadPool(const ThreadPool &) = delete;
const ThreadPool &operator=(const ThreadPool &) = delete;
public:
void Lock()
{
pthread_mutex_lock(&mutex_);
}
void Unlock()
{
pthread_mutex_unlock(&mutex_);
}
void Wakeup()
{
pthread_cond_signal(&cond_);
}
void ThreadSleep()
{
pthread_cond_wait(&cond_, &mutex_);
}
bool IsQueueEmpty()
{
return tasks_.empty();
}
std::string GetThreadName(pthread_t tid)
{
for (const auto &ti : threads_)
{
if (ti.tid == tid)
return ti.name;
}
return "没有这个线程";
}
public:
static void *HandlerTask(void *args) // 特别注意,这个方法是静态的!
{
ThreadPool *tp = static_cast *>(args);
std::string name = tp->GetThreadName(pthread_self());
while (true)
{
tp->Lock();
while (tp->IsQueueEmpty()) // 同样使用循环来防止伪唤醒的情况
{
tp->ThreadSleep();
}
T t = tp->Pop();
tp->Unlock();
t(); // 处理任务放在加锁之后,使处理操作和临界区的代码可以并行进行
std::cout << name << " 运行, "
<< "结果: " << t.GetResult() << std::endl;
}
}
void Start()
{
int num = threads_.size();
for (int i = 0; i < num; i++)
{
threads_[i].name = "线程-" + std::to_string(i);
pthread_create(&threads_[i].tid, nullptr, HandlerTask, this); // 注意这里参数传的使this指针!
}
}
T Pop()
{
T t = tasks_.front(); // 这里不用加锁,因为调用它的地方已经在锁里面了
tasks_.pop();
return t;
}
void Push(const T &t)
{
Lock();
tasks_.push(t);
Wakeup();
Unlock();
}
};
template //在类外定义
ThreadPool *ThreadPool::tp_ = nullptr;
template
pthread_mutex_t ThreadPool::lock_ = PTHREAD_ADAPTIVE_MUTEX_INITIALIZER;
STL的线程安全
STL的设计初衷是为了将性能挖掘到极致,一旦加锁就会使性能大打折扣。因此STL的线程默认是不安全的,需要调用者自行保证其线程安全。
智能指针的线程安全
首先是unique_ptr,它由于只在当前代码块中生效,因此不涉及线程安全问题。
而shared_ptr,因为多个对象会用到同一个计数器,所以本来会存在线程安全问题,但是标准库中考虑到了这个问题,基于原子操作(CAS)的方式保证了shared_ptr能够高效,也就是原子操作的计数器。
其他常见的锁
悲观锁
每次读取数据时,总是担心数据会被其他线程所修改,所以会在取得数据前加锁, 这时当其他线程想要访问数据时,会被阻塞挂起。这也是迄今我们一直在用的锁。
乐观锁
每次取数据前,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作
自旋锁
这个锁其实不是很常用,通常在游戏领域可能会用到。这个我们只是简单说说。
一般来说,当一个线程访问的数据已经被上锁时,会挂起等待。但是自定总是会挂起等待,也有可能会一直重复做申请锁的动作,直到申请到锁。自旋锁会根据其他线程执行临界区的时长来决定是挂起,还是一直重复去申请。
读写锁
为了能更好的理解,我们可以拿古时的通缉令打比方,通缉令这个东西,一旦写好了就放在告示牌上贴着,这时就会来很多人,它们可以一起来看通缉令的内容,当通缉令需要更新时,此时只有一根来更新通缉令,而且在更新完之前,别人也看不了。
所以相比较生产者消费者模型,读写问题也是321原则,不过在关系方面略有不同。
比如1,是例子中的告示牌,也就是一个缓冲区。
2指的是两个角色,是例子中看通缉令和写通缉令的人,也就是生产者和消费者。
3依旧是指三种关系,生产者和生产者是互斥关系,生产者和消费者是同步且互斥的关系,而消费者和消费者确实共享的关系。
所以读写问题唯一与生产者消费者模型不同点在于消费者和消费者之间是共享关系。
读写锁诞生的原因,因为有时候对于一个共享资源,大多时候都是在读取,修改少,而在读的过程中往往伴随着查找,因此耗时较长,给这部分代码加锁,会极大的降低程序的效率,因此就出现了读写锁。
初始化
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t
*restrict attr);
销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
上锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
通过上锁接口我们发现与之前的锁有点不一样,对于读和写上锁的接口不同,但是解锁是一样的。
这是因为,读和写的同步问题,当一个数据正在被读取时,现在有两个线程,一个要修改,一个要读取,为了保证有序,我们分成了读优先和写优先。
在初始化后,可以通过以下这个接口来设置读优先还是写优先
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
/*
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG,导致表现行为和
PTHREAD_RWLOCK_PREFER_READER_NP 一致
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
关于读写锁的使用,除了接口不同,还有就是在初始化后要设置这个锁的属性之外,用法和之前的锁一样,所以不再演示,我们通过伪代码的方式演示一下读写锁以读优先的原理
以下是以读优先的伪代码,理解rwlock的实现原理
int reader_cuont = 0; //读者计数器,记录当前有多少个读者
mutex_t rlock,wlock; //就是正常的两把锁
对于读者加锁 && 解锁
lock(&rlock);
reader_cuont++;
if(reader_cuont == 1) lock(&wlock);
unlock(&rlock);
//进行读取操作
lock(&rlock)
reader_cuont--;
if(reader_cuont == 0) unlock(&wlock);
unlock(&rlock);
对于写者 加锁 && 解锁
lock(&wlock);
//进行写入操作
unlock(&wlock);
可见,对于读优先时,读者的操作就是对于增减这个计数器的时候会加锁,进行耗时的读取数据操作是没有锁的,另外因为是读优先,所以只要读者不为0,在读这边就会给写加锁,直到读者为0。
而对于写者,就是正常的加锁解锁操作。这就是读写锁以读优先的基本原理,这样会存在写者饥饿问题。
明白了读优先,写优先的原理也自然就懂了,优缺点也是,这就是读写锁。
涉及到了数据的运算,则涉及从内存加载数据到寄存器,在寄存器中运算,将寄存器中数据交还内存的过程因此需要加锁保护的操作中。
原子性:一个操作不会被打断,要么一次完成,要么不做。
可见性:一个资源被修改后,是否对其他线程是立即可见的(一个变量的修改存在一个过程,将数据从内存加载的cpu寄存器,进行运算,完毕后交还内存,但是这个过程在代码优化中可能会被编译器优化,将数据放入寄存器,则后续运算只从寄存器取数据,就节省了从内存获取数据的时间)
有序性:简单理解,程序按照写代码的先后顺序执行,就是有序的。(编译器有时候会为了提高程序效率进行代码优化,进行指令重排,来提高效率,而有序性就是禁止指令重排)
而volatile关键字的作用是,防止编译器过度优化,也就是可以保证可见性和有序性。
所谓原语的原子性操作是指一个操作中的所有动作,要么成功完成,要么全不做。也就是说,原语操作是一个不可分割的整体。为了保证原语操作的正确性,必须保证原语具有原子性。在单机环境下,操作的原子性一般是通过关闭中断来实现的。由于中断是计算机与外设通信的重要手段,关闭中断会对系统产生很大的影响,所以在实现时一定要避免原语操作花费时间过长,绝对不允许原语中出现死循环。