在本篇博客中,作者将会带领你在Linux中理解和实现多线程编程。
本篇博客都是的代码示例都是使用C/C++代码来完成的。
一.什么是线程
当你学到多线程的时候,那么你已经大概率学过进程了,那么进程和线程又有什么区别呢?
进程是资源分配的基本单位,线程是CPU调用的基本单位!!!
光看上面这句话来理解是很笼统的,所有我们来详细的讲解一下。
首先我们需要理解什么进程。
对于进程的讲解,在下面的这篇博客中,我已经有详细的讲到,所以在这里就不再多说。
【Linux】进程的基本概念(以及进程地址空间的初步了解)_linux进程空间-CSDN博客
对于进程的理解,我们可以用下图来表示。
当程序被加载到内存中的时候,操作系统为了能管理这些进程,会为这些程序创建PCB进程控制块,进程地址空间和页表,当CPU真正执行这个程序的时候,本质是将PCB进程控制块放入CPU运行队列中,再通过CPU来运行,通过PCB进程控制块,我们能拿到整个程序的信息。
这就是进程。
但是我们在开头讲到,线程是CPU最基本的调度单位,那这是不是和这里的进程的概念冲突了呢?
其实不是的,像上面这个图中的进程,也可以叫做只有一个线程的进程,因为它只有一个PCB进程控制块。
在进程中,我们知道有一个fork函数,通过这个函数,我们可以派生一个子进程,通过这个子进程,我们可以让它执行一部分代码,但是这样的代价是很大的,因为当我们派生一个子进程的时候,操作系统需要将父进程完全拷贝一份(拷贝PCB进程控制块、进程地址空间,页表)。
那么如果我们想要执行代码中的一部分,还有没有其他的办法呢,答案就是创建一个线程。
在Linux中创建线程的本质是在该进程中创建多一个PCB进程控制块。
如下图所示:
通过这样的方式,我们就不需要像创建子进程那样付出很大的代价,只需创建一个新的PCB进程控制块即可,因为这个新的PCB进程控制块共享我们的进程地址空间,从而也能看到我们程序的代码和数据。
所以在Linux中,线程也被叫做轻量级进程。
注意!!!
这只是在Linux中线程的实现方法,因为编写Linux系统的程序员发现,进程和线程非常的相似,几乎可以说一模一样,所以Linux的程序员选择直接复用PCB进程控制块是来实现线程。
而在windows中,线程的实现则是重新设计了一种新的数据结构TCB来实现的。
总结
进程是资源分配的基本单位,线程是CPU调度的基本单位。
进程用来整体申请资源,线程用来伸手向进程要资源。
一个进程内部可以有多个执行流,其中一个线程就是一个执行流。
如果你还是不是很理解,那么我可以举一个例子来帮你理解:
进程我们可以比作一个公司内部的工作室,而这个工作室内部的员工可以比作是线程,当这个工作室向公司要资源的时候,例如经费,是这个工作室在要,即进程向操作系统要资源,而底下的员工在进行工作,就是线程在被CPU进行调度。
工作室就是进程,员工就是线程。
所以线程的本质是进程中的一个执行流,而进程中的执行流是可以创建多个的,创建多一个执行流只需要多创建一个PCB进程控制块即可。
二.如何创建线程
知道了什么是线程后,我们就可以来创建线程了。
在Linux中,没有真正意义上的线程,因为Linux中线程的实现是通过复用PCB进程控制块来实现的,所以在Linux中线程也被叫做轻量级进程。
所以在Linux中并没有给我们提供系统调用来创建线程,但是在Linux中存在这一个原生线程库,这个原生线程库中给我们提供了创建线程的方法。
那么这个原生线程库又是如何实现的呢?
这个原生线程库底层调用的是clone系统调用来实现的,在这里不做讲解。
那既然创建线程需要用到原生线程库,所以在编译我们的程序的时候需要说明要链接原生线程库。如下图所示:
g++ test.cpp -o test -lpthread
说明链接pthread库。
第一个多线程程序
要想创建线程,首先我们要先来学习一下关于创建线程的函数。
pthread_create函数
pthread_create函数是创建一个线程。它的手册如下图所示。
这个函数的参数有四个,其中,我们只关心三个:thread、start_routine、arg。
thread:
作为一个线程,那么它就一定会有一个特定的标识符id,这个线程id的类型是pthread_t。
通过查看定义,我们可以看到,其实这个pthread_t类型就是一个无符号long int类型。
attr:
这个参数是用来设置线程的属性的,我们一般不关心,设置为nullptr即可。
start_routine:
这个参数是一个函数指针,来指定我们的线程去执行那个函数。其中这个函数的返回值是void*,参数类型也是void*。
arg:
这个参数是给start_routine函数传递参数用的。
返回值:
成功时,pthread_create()返回0;发生错误时,它会返回一个错误编号,*thread的内容是未定义的。
示范demo
在下面这个代码中,我在主线程中创建了一个从线程,然后主线程和从线程都一起同时运行。
#include <iostream>#include <pthread.h>
#include <unistd.h>using namespace std;void *start_routine(void *args)
{while (true){cout << "我是新线程" << (char *)args << endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;//定义线程tidpthread_create(&tid, nullptr, start_routine, (void *)"hello linux");//最后一个参数传给了start_routine的argswhile (true){cout << "我是主线程" << std::endl;sleep(1);}return 0;
}
运行效果如下图所示:
从运行效果上来看,我们可以发现,主线程和新线程进行向屏幕打印的实现,但是打印发生了冲突,对于这个现象,会在后面提到。
ps -aL指令
当我们上面的程序跑起来的时候,很明显,有两个执行流在运行,一个是主线程,一个是新线程,当我们使用ps ajx指令查看进程的时候,如下图所示:
在这里只能看到一个进程./mythread,那是因为这里的./mythread是这个进程的总体。
如果我们想要查看这个进程的所有执行流,即所有线程的属性,就要用到ps -aL指令。
如下图所示:
ps -aL指令是来查看轻量级进程的,通过图我可以发现,这两个线程有着相同的PID,因为这两个线程都属于mythread进程。
继续往后看,可以发现还有一个LWP属性,这个LWP的全称为light weight process(轻量级进程id),其中,主线程的LWP与PID相同。
LWP与pthread_t
在上面的demo代码中,有个用来标识线程的pthread_t变量,而在上面又有一个LWP的id,那么这两个id有什么区别呢?
注意,这两个东西不是同一个,LWP是用来标识我们的线程id的,是真正意义上的线程id,我们可以使用gettid函数去获取一个线程的id,而pthread_t中存的变量其实是一个地址,至于这个地址是什么,后面再讲解,我们先来看一下这个地址。
我通过下面这个代码将这个tid的信息输出出来。
#include <cstdio>
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void *start_routine(void *args)
{while (true){}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, start_routine, (void *)"hello linux");char buffer[64];while (true){snprintf(buffer, sizeof(buffer), "0x%x", tid); // 将tid转换成八进制到buffer字符串中cout << "我是主线程,我创建出来的pthread_t tid是:" << buffer << std::endl;sleep(1);}return 0;
}
运行结果:
三.线程的特性 (tid是什么,threadlocal技术)
在上面,我们已经能够成功的创建出新线程了,接下来了解一下线程的特性。
1.线程一旦被创建,几乎所有的资源是共享的。
通过上面这个图我们可以看到,所有的线程都是共享一个进程地址空间的,所以线程和线程之间几乎所有的资源是共享的。例如全局变量,全局函数等等资源。所以线程和线程之间交互数据是非常容易的。
2.线程也要有自己的私有成员。
在上一条中,我们讲到,线程和线程之间几乎所有的资源是共享的,但是还有一些资源是私有的。
线程的PCB进程控制块是私有的。
线程的上下文数据是私有的。
线程都会有自己的私有栈结构,线程所执行的函数的内部的变量是线程私有的,是存储在这个线程的私有栈中。但是通过上面的图,我们可以看到在地址空间中,只有一个栈区,那么这个每个线程的独立栈结构是如何实现的呢?
我们来看下图。
那么每个线程的独立栈结构是保存在那的呢?
每个线程的独立栈结构是保存在mmap区域中的,也可以说是进程地址空间中的共享区。
我们把mmap区域单独拿出来看,在这个区域内部会存储的每个线程的结构体struct pthread,这个结构体中就存储着每个线程的信息,其中pthread_t tid这个值就是这个结构体的起始地址。所以这也回答了我们上面的问题,这个pthread_t tid的值到底是什么。
观察这个struct pthread结构体,可以看到里面还有一个信息,线程局部存储。
那么这个线程局部存储是什么呢?
我们知道,在一个进程中,一个全局变量是被所有线程共享的,那么如果我不想这个全局变量被共享呢?即threallocal技术。
我们可以在这个全局变量前面加上__thread,那个这个全局变量就不会再被共享了,每个线程都会有一个独立的变量,这个变量是存储在每个线程的线程局部存储区域的。
如下图代码所示:
#include <iostream>
#include <pthread.h>
#include <unistd.h>using namespace std;__thread int global = 0;void *start_routine(void *args)
{while (true){cout << "我是新线程,global:" << global++ << " " << &global << endl;sleep(1);}
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, start_routine, nullptr);while (true){cout << "我是主线程,global:" << global << " " << &global << endl;sleep(1);}return 0;
}
运行效果:
通过运行效果可以看到,主线程和新线程的global不是同一个,它们的地址是不同的。
线程的优点
1.创建一个新线程的代价要比创建一个新进程小得多。2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。3.线程占用的资源要比进程少很多。4.能充分利用多处理器的可并行数量。5.在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。7.I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
对于线程的优点,我们可以详细的讲一下。
1.2.为什么创建线程的代价和线程之间的切换代价要小很多。
因为创建一个新线程,只需要创建一个新的PCB进程控制块即可,而创建一个新进程不仅要创建PCB进程控制块,还要创建地址空间和页表。
进程之间的切换,需要切换PCB,上下文数据,地址空间,页表,而线程之间的切换,只需要切换PCB和上下文数据即可。
线程之间的切换,CPU三级缓存中的数据不需要大量的更新。当我们的CPU运行程序的时候,会将该程序的数据先加载到CPU三级缓存中再运行,而线程切换的时候,缓存里的数据不需要大量的更新,而进程的切换需要大量的更新。
4.现在的CPU几乎都是多核的,所以当一个CPU在运行一个进程的时候,就可以把每个线程放到不同的核上去运行,从而达到真正并行的效果。
线程的缺点
1.性能损失一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的 同步和调度开销,而可用的资源不变。2.健壮性降低编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。3.缺乏访问控制进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。4.编程难度提高编写与调试一个多线程程序比单线程程序困难得多。
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随之崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退。所以一个线程因为异常退出,整个进程也会随之退出。同时,如果主线程比其他线程先退出,整个进程也会退出,导致其他线程也退出。
四.创建一批线程
在上面,我们已经知道了如何创建一个线程,那么一批线程又应该如何创建呢?
在本博客后面的内容中,会经常出现创建一批线程的场景,所以在这里先熟悉一下。
我们来演示一下。
#include <cstdio>
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define NUM 10//线程个数class ThreadData//定义一个类用来存储线程的一些信息
{
public:pthread_t tid;char buffer[64];
};void *start_routine(void *args)
{int tmp = 10;while (true){cout << "我是" << (char *)args << ",我的tmp是" << tmp << ",tmp的地址是" << &tmp << endl;sleep(1);}return nullptr;
}int main()
{vector<ThreadData *> vtid;for (int i = 0; i < NUM; i++){ThreadData *td = new ThreadData;snprintf(td->buffer, sizeof(td->buffer), "thread%d", i);pthread_create(&td->tid, nullptr, start_routine, (void *)td->buffer);vtid.push_back(td);}while (true){;}return 0;
}
运行效果:
通过运行效果,我们成功的创建出来了十个线程,同时我们也发现了,在每个线程的tmp变量中,它们都不是同一个,因为它们的地址不相同。
所以每个线程都有自己的私有栈结构。
五.线程的终止与等待
接下来我们来讲一下关于线程的终止和等待问题。
线程终止
在进程中,我们知道有一个exit函数,这个函数可以将进程终止掉。
那么这个函数能不能终止我们的线程呢?答案是不可以的。
exit函数是用来终止进程的,如果用来终止线程,则整个进程所有的线程都是被终止掉。
那么如果我想想要终止一个线程,有一下三种方法:
1.通过线程函数return终止,这相当于main函数通过return 0结束一样。
2.在线程函数中通过调用pthread_exit函数进行终止。
3.一个线程通过调用pthread_cancel函数去终止同一进程中的另一个线程。
对于第一种方法来说,这个就没什么好解释的了,就不再演示,就是当这个线程所运行的函数运行完后,线程就会自动退出。
pthread_exit函数
这个函数是用来终止当前线程,我们来看一下它的手册。
这个函数的使用非常简单,直接调用即可,而且这个函数有一个参数,那么这个参数是什么呢?
先不着急,这个参数是配合pthread_join来使用的,后面会讲到,我们先来看看这个函数的用法。
#include <cstdio>
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;void *start_routine(void *args)
{int cnt = 10;while (cnt--){cout << "我是" << (char *)args << ":" << cnt << endl;if (cnt == 5){pthread_exit(nullptr);//终止当前线程}sleep(1);}
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, start_routine, (void *)"新线程");while (true){cout << "我是main主线程" << endl;sleep(1);}return 0;
}
运行效果:
当新线程运行了五秒后,就会调用pthread_exit函数而导致其线程退出。
pthread_cancel函数
这个函数是在另一个线程中,去终止当前进程中的某个线程。
我们来看一下这个函数的手册,这个函数的参数是一个线程的id值,调用后,会终止该id的线程。用法如下图所示。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void *start_routine(void *args)
{while (true){cout << "我是新线程" << endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, start_routine, nullptr);int cnt = 5;while (true){if (cnt < 0){pthread_cancel(tid);//在主线程中,终止新线程}cout << "我是主线程" << endl;cnt--;sleep(1);}return 0;
}
运行效果:
通过运行效果我们可以看到,五秒后,新线程就被取消掉了,只有主线程再跑。
线程等待
对于一个线程来说,如果不进行等待,那么会造成类似僵尸进程的问题,导致内存泄漏。
所以线程是需要被等待的,同时线程等待也可以获取线程的退出信息,也就是上面pthread_exit的参数,或者线程所执行函数的return返回值。
那么线程应该如何等待呢?
通过调用pthread_join函数来进行等待。
其中,这个函数又两个参数:
thread:
这个参数是需要等待进程的id。
retval:
线程退出时,会有一个返回值,也就是pthread_exit的参数以及线程所调用函数的void*返回值,这个返回值会返回给retval这个二级指针。
那么pthread_join如何使用呢,我们在看一下示例。
#include <cstdio>
#include <iostream>
#include <vector>
#include <pthread.h>
#include <unistd.h>
using namespace std;
#define NUM 10class ThreadData
{
public:pthread_t tid;char buffer[64];
};void *start_routine(void *args)
{int tmp = 10;int count = 3; // 循环次数while (count--){cout << "我是" << (char *)args << ",我的tmp是" << tmp << ",tmp的地址是" << &tmp << endl;sleep(1);}return nullptr;
}int main()
{vector<ThreadData *> vtid;for (int i = 0; i < NUM; i++){ThreadData *td = new ThreadData;snprintf(td->buffer, sizeof(td->buffer), "thread%d", i);pthread_create(&td->tid, nullptr, start_routine, (void *)td->buffer);vtid.push_back(td);}// 进行线程的等待for (int i = 0; i < NUM; i++){pthread_join(vtid[i]->tid, nullptr); // 等待线程退出cout << "线程 : " << vtid[i]->tid << " 等待退出成功" << endl;}return 0;
}
运行结果:
线程函数的返回值
我们可以发现,在线程所调用的函数中,这个函数的返回值是void*,那么这个返回值是干嘛用的呢?
这个返回值可以允许我们将我们想要的数据通过指针带回来。
例如,如果我在start_routine函数中new了一个对象,然后我想把这个对象带回给主线程,就可以用到pthread_exit的参数或者通过return带会到主线程,然后主线程通过pthread_join的第二个参数获取到。
如下面代码所示:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void *start_routine(void *args)
{int cnt = 5;while (cnt--){cout << "我是" << (char *)args << ",cnt:" << cnt << endl;sleep(1);}int *ret = new int;*ret = 10000;// return (void *)ret;//第一种方式pthread_exit((void*)ret);//第二种方式
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, start_routine, (void *)"thread1");void *accept = nullptr;pthread_join(tid, &accept);cout << *((int *)accept) << endl;return 0;
}
运行效果:
对于这里线程函数的返回值问题以及pthread_join的参数问题,可能会有很多人会有疑问,因为这里是一个二级指针,很容易把人搞蒙,所以在这里再解释一下。
整个过程如下图所示。
线程分离
在上面我们讲到了通过pthread_join函数可以进行线程的等待,但是主线程在等待新线程的时候,主线程是什么都不能做的,处于被阻塞状态,这样就会给主线程造成负担,同时,对于新线程的返回值,我们有时候也没必要关心,所以当我们不关心新线程的返回值的时候,没必要进行线程的等待,我们可以通过进行线程分离,来告诉新线程,等它结束后自动释放线程资源。
pthread_detach函数
通过调用pthread_detach函数,我们可以是分离线程。我们来看一下这个函数的手册。
这个函数的参数只有一个,即线程的id。调用这个函数后,该线程就会和主线程进行分离,所以主线程也不再需要对该线程进行等待。
使用方法如下:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;void *start_routine(void *args)
{int cnt = 5;while (cnt--){cout << "我是新线程" << endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, start_routine, nullptr);pthread_detach(tid);//进行线程分离while(true){;}return 0;
}
六.封装原生线程库
对于多线程部分,如果你用过C++的线程库,你会发现,C++的实现多线程方式明显比用原生线程库的方法简单的多,那是因为在C++中,对原生线程库进行封装,使得我们使用起来非常好用,所以在这里,我们也来对原生线程库进行封装一下,来实现自己的线程操作。
首先我们先创建一个"Thread.hpp"文件,再这个文件中编写我们的代码。
// 对原生线程库的线程函数进行封装
#pragma once
#include <cstdio>
#include <cassert>
#include <string>
#include <pthread.h>class Thread
{const int size = 1024;typedef void *(*func_t)(void *);//重定义函数指针
public:// 构造函数时,给线程创建一个名字Thread(){char buffer[size];snprintf(buffer, sizeof(buffer), "thread-%d", _threadnum++);_name = buffer;}// 调用start函数时才是真正的创建线程void start(func_t func, void *args = nullptr){_func = func;_args = args;int n = pthread_create(&tid, nullptr, _func, _args);assert(n == 0);}// 线程退出void join(){int n = pthread_join(tid, nullptr);assert(n == 0);}// 获取线程名std::string getname(){return _name;}// 析构函数~Thread(){//什么都不做}private:pthread_t tid; // 线程idstd::string _name; // 线程名static int _threadnum; // 线程序号func_t _func; // 线程需要执行的函数void *_args; // 线程传递的参数
};
int Thread::_threadnum = 0;
对原生线程库进行封装后,后续当我们需要用到创建新线程的时候,就不再需要像之前那样那么麻烦了,所以在后续的代码中,我们要创建线程时,统一使用这个封装后的创建线程的方法。下面演示一下封装后的用法。
示范demo
#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"using namespace std;void *start_routine(void *args)
{while (true){cout << "我是新线程,我的线程名是:" << (char *)args << endl;sleep(1);}return nullptr;
}int main()
{vector<Thread *> vt; // 定义容器存放线程Thread的地址// 创建一批线程for (int i = 0; i < 5; i++){Thread *t = new Thread;t->start(start_routine, (void *)t->getname().c_str());vt.push_back(t);}for (int i = 0; i < 5; i++){vt[i]->join();}return 0;
}
运行效果:
运行后,五个线程同时跑起来。
后续在所有有创建线程的地方均使用上面自己封装的线程类。
七. 线程互斥
什么是线程互斥?
我们知道,在一个进程中,几乎所有的资源都能被线程共享,这个时候就会有一个问题,如果有多个线程同时访问了一个被共享的资源会怎么样的?很显然这种操作是不安全的,所以才要有线程的互斥。
示范demo
举个例子:
比如一个买火车票的软件,现在有很多人对这个火车票进行抢票,其中每个人都代表着一个线程,当某个线程抢到票的时候,就要对火车票进行减减操作,对于这个过程,我们可以用如下代码来演示:
#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"
using namespace std;
int ticket = 10000; // 火车票的票数void *start_routine(void *args)
{while (true){if (ticket > 0)// 如果票数大于0,才进行抢票{usleep(1000); // 让打印慢一点,方便我们观察cout << "我是" << (char *)args << ":我正在抢票:" << ticket-- << endl;}else{break;}}return nullptr;
}int main()
{vector<Thread *> vt; // 定义容器存放线程Thread的地址// 创建五个线程去抢票for (int i = 0; i < 5; i++){Thread *t = new Thread;t->start(start_routine, (void *)t->getname().c_str());vt.push_back(t);}// 等待线程退出for (int i = 0; i < 5; i++){vt[i]->join();}return 0;
}
运行结果:
对于运行结果来说,我们只需要看最后的一部分即可。
通过运行结果我们可以看到,到最后,抢票的时候,把我们的票数抢到负数去了,这是为什么呢?
因为我们再对ticket进行减减操作的时候,这个ticket是一个临界资源,因为它被所有的线程所共享,所以对ticket进行操作的时候是不安全的。
当多个线程都在跑start_routine函数的时候,有可能多个线程同时进入了if条件内部,这就导致了当ticket=1的时候,还会有多个线程在if条件内部,导致对ticket的减减操作执行了超过一次以上,从而引发问题。
对于这个问题如何解决,我们先来了解一下专业术语:临界资源: 多线程执行流共享的资源就叫做临界资源。例如上面的ticket变量临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。互斥: 任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。原子性: 不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。
互斥量mutex
那么对于上面这种情况,我们应该如何解决呢?
答案是使用互斥锁。 在上面的抢票代码中,使用互斥锁有就不会再引发问题了。
因为当一个线程使用了互斥锁加锁后,当其他的线程来访问这部分被加锁的代码时,是不能被访问的,需要等上一个拿了锁的线程把锁释放后,且该线程拿到了锁才能进入临界区,在其他线程等待获取互斥锁的过程中,它们是被阻塞的,这样从而阻止了多个线程同时进入临界区对临界资源进行操作,使得这部分临界资源是安全的。
如下面代码所示:
互斥锁示范demo
#include <iostream>
#include <vector>
#include <unistd.h>
#include "Thread.hpp"
using namespace std;
int ticket = 10000; // 火车票的票数pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 初始化锁void *start_routine(void *args)
{while (true){pthread_mutex_lock(&lock); // 加锁if (ticket > 0){usleep(1000); // 让打印慢一点,方便我们观察cout << "我是" << (char *)args << ":我正在抢票:" << ticket-- << endl;pthread_mutex_unlock(&lock); // 解锁}else{pthread_mutex_unlock(&lock); // 解锁break;}usleep(500);}return nullptr;
}int main()
{vector<Thread *> vt; // 定义容器存放线程Thread的地址// 创建五个线程去抢票for (int i = 0; i < 5; i++){Thread *t = new Thread;t->start(start_routine, (void *)t->getname().c_str());vt.push_back(t);}// 等待线程退出for (int i = 0; i < 5; i++){vt[i]->join();}return 0;
}
通过上面的代码,再去运行模拟抢票的程序,就不会出现问题了。
互斥锁的使用
在上面的demo代码中,我们直接简单的了解了一下互斥锁是怎么样的,那么互斥锁的具体又是如何使用的呢?我们来看一下。
互斥锁的使用有两种方式,其中,使用的方法和函数如下图所示。
第一种方法
首先我们来看一下互斥锁的第一种用法。锁直接定义在全局。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; // 初始化锁
就跟上面的互斥锁示范demo一样的用法。需要使用的时候,直接加锁解锁即可。
第二种方法
互斥锁在局部定义。当我在局部定义了一个互斥锁的时候,如果想要在一个线程函数里面使用这个互斥锁,那么只需要有这个互斥锁的地址即可。
那么第二种锁的用法又是怎么样的呢?我们来看一下示范代码。
在这段代码的演示中,我需要每一个线程都共用一把锁,这样才能达到互斥的效果,但是我又希望能传递参数给线程,但是线程所执行的函数只有一个void*的参数,那应该如何解决呢?
我们可以定义一个ThreadData类,这个类里面存储着我们希望传递给线程所执行函数的参数,后续直接传这个ThreadData对象给我线程所执行的函数就好了。
如下图所示:
#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include "Thread.hpp"using namespace std;int ticket = 10000; // 火车票的票数// 顶一个存放线程数据的结构体,里面包含锁和线程名
struct ThreadData
{ThreadData(pthread_mutex_t *lock, const string threadname): _lock(lock), _threadname(threadname){}pthread_mutex_t *_lock;string _threadname;
};void *start_routine(void *args)
{ThreadData *td = (ThreadData *)args;while (true){pthread_mutex_lock(td->_lock); // 加锁if (ticket > 0){usleep(1000); // 让打印慢一点,方便我们观察cout << "我是" << td->_threadname.c_str() << ":我正在抢票:" << ticket-- << endl;pthread_mutex_unlock(td->_lock); // 解锁}else{pthread_mutex_unlock(td->_lock); // 解锁break;}usleep(500);}return nullptr;
}int main()
{vector<Thread *> vt; // 定义容器存放线程Thread的地址pthread_mutex_t mtx; // 定义一把共用的互斥锁// 创建五个线程去抢票for (int i = 0; i < 5; i++){char buffer[64];snprintf(buffer, sizeof(buffer), "thread:%d", i);ThreadData *td = new ThreadData(&mtx, buffer);Thread *t = new Thread();t->start(start_routine, td);vt.push_back(t);}// 等待线程退出for (int i = 0; i < 5; i++){vt[i]->join();}return 0;
}
互斥锁的原理
对于互斥锁的使用和作用我们都清楚了,那么对于互斥锁的原理又是怎样的呢,它是如何实现加锁和解锁的?
首先我们来看一个问题:
对于i++这条语句来说,它不是原子性的,因为这个操作需要由三条指令来完成。
①将i变量从内存加载到CPU的寄存器中。
②在CPU的寄存器中对i进行++。
③将i变量从CPU的寄存器中加载回内存中。
正因为i++的操作需要三条指令来完成,所以在多线程环境中,如果不加锁,就会出现问题。
比如说,当线程1执行了①指令后,它的时间片到了,被切了出去,然后线程2进来的,也执行了①指令,这个时候两个线程进行++的操作就会出现数据一致性的问题。
但是如果i++的操作能由一条指令来完成,那么i++的操作就是原子性的(但是这种情况是不存在的,只是进行一个比喻)。
那么加锁也是一行代码的操作,它可能也需要若干条指令来完成,那么加锁的操作有没有可能会像i++操作那样出现问题呢?
答案是肯定不会。
因为我们只需要保证加锁的过程是原子性的(即获取锁的过程是由一条指令来完成的),那么加锁这个操作才是合理的,那它是怎么实现的呢?
我们往下看。
首先我们需要达成一些共识:
在现在的计算机体系结构里面,都提供了一条swap或者exchange指令,这条指令可以直接把内存和CPU寄存器中的数据进行交换,由于只有一条指令,所以它是原子性的。
当CPU在调度一个执行流的时候(即调度一个线程),这个过程中势必会产生该线程的上下文数据,当该线程时间片到了被切出去的时候,该线程的上下文也需要被切出去,以便下一次被调度的时候再加载回来,所以一个线程的上下文数据会被保存在该线程的PCB中。
加锁过程
首先pthread_mutex_t其实也是一个变量(其实一个联合体,这个联合体里面又会一个结构体),在这个结构体里面,有一个int类型变量,初始值为1。
对于pthread_mutex_lock语句来说,它可以由下面这几条指令组成。
下面我将一条指令一张图来进行讲解。
①当线程A执行到movb $0,%al。
当执行完这条指令后,线程A是有可能因为时间片到了,被切出去,但是不影响。因为真正的加锁指令还未执行。
同时,即使线程A被切出去了,它的上下文数据也会被一起带走,即0会被带回到线程A的PCB中。
②当线程A执行到xchgb %al,mutex。
将寄存器中的0,和pthread_mutex_t中的1进行交换。
这个时候线程A可以说是已经持有锁了,即使线程A被切出去了,寄存器中的1也会被保存到线程A的PCB中。即表示获取锁。
③进行判断,如果寄存器中的值>1,则线程A加锁成功。
如果不大于1,则说明锁被其他线程持有了,线程A被挂起阻塞等待。
这就是整个加锁的过程。
这个时候你可以试着分析一下,如果在执行任何一条指令后,线程都会被切出去,会发生什么。
结论是,加锁这个过程是原子的,不会被影响。
解锁过程
解锁的过程非常简单,直接将寄存器中的1写回到pthread_mutex_t变量中即可,即把锁还了会去,解锁成功。
八.封装原生锁
在上面的代码中,每一次加锁后,都要进行解锁,而且对于什么时候进行解锁也非常重要,因为可能解锁的位置不当就会造成死锁的问题,那么有没有一种方法可以避免这种情况发生了,答案是有的。
如果你用过C++库里面的锁就知道了,我们可以使用RAII的思想来设计锁。
如下面代码所示。
我们利用LockGuard的构造函数来加锁,利用LockGuard的析构函数来解锁,这样我们进行加锁的时候,就不用显示的去解锁了。
#pragma once#include <pthread.h>class Mutex
{
public://构造函数Mutex(pthread_mutex_t* lock = nullptr):_lock(lock){//什么都不做}//加锁void lock(){if(_lock != nullptr)pthread_mutex_lock(_lock);}//解锁void unlock(){if(_lock != nullptr)pthread_mutex_unlock(_lock);}//析构函数~Mutex(){//什么都不做}
private:pthread_mutex_t* _lock;
};class LockGuard
{
public://构造函数LockGuard(pthread_mutex_t* mutex):_mutex(mutex){_mutex.lock();}//析构函数~LockGuard(){_mutex.unlock();}
private:Mutex _mutex;
};
当我们封装了一个LockGuard锁后,加锁的代码就可以这样写了。
#include <iostream>
#include <vector>
#include <string>
#include <unistd.h>
#include "Thread.hpp"
#include "Mutex.hpp"using namespace std;int ticket = 10000; // 火车票的票数// 定义一个存放线程数据的结构体,里面包含锁和线程名
struct ThreadData
{ThreadData(pthread_mutex_t *lock, const string threadname): _lock(lock), _threadname(threadname){}pthread_mutex_t *_lock;string _threadname;
};void *start_routine(void *args)
{ThreadData *td = (ThreadData *)args;while (true){{LockGuard lock(td->_lock);if (ticket > 0){usleep(1000); // 让打印慢一点,方便我们观察cout << "我是" << td->_threadname.c_str() << ":我正在抢票:" << ticket-- << endl;}else{break;}}usleep(500);}return nullptr;
}int main()
{vector<Thread *> vt; // 定义容器存放线程Thread的地址pthread_mutex_t mtx; // 定义一把共用的互斥锁// 创建五个线程去抢票for (int i = 0; i < 5; i++){char buffer[64];snprintf(buffer, sizeof(buffer), "thread:%d", i);ThreadData *td = new ThreadData(&mtx, buffer);Thread *t = new Thread();t->start(start_routine, td);vt.push_back(t);}// 等待线程退出for (int i = 0; i < 5; i++){vt[i]->join();}return 0;
}
九.死锁
什么是死锁?
死锁是指两个或多个执行流在执行过程中,因为争夺资源而造成的一种相互等待的状态。在这种状态下,进程无法继续执行,因为每个执行流都在等待其他执行流释放它所需要的资源,从而导致程序无法向前推进。
光看上面这段文字很难明白,我来举个例子来说明:
假设现在在一个进程中,有两把锁,两个新线程,且有两个全局变量a和b,在线程A中,因为要访问变量a,所以对变量a进行了加锁,同时线程B要访问变量b,对变量b进行了加锁。这个时候还没有问题。然后线程A运行了一会后,需要用到变量b,但是变量b被线程B所持有,所以线程A只能等待线程B释放变量b,而这个时候,线程B也需要用到变量a,但是变量a被线程A所持有,所以线程B也在等待,就形成了两个线程相互等待的情况,导致无法运行一直等待下去。
死锁的四个必要条件
死锁只有满足四个条件下才会发生。
互斥条件:一个资源一次只能被一个执行流使用。
请求与保持:一个执行流因请求别的资源而被阻塞时,对已获得的资源不释放。
不剥夺条件:一个执行流已获得的资源,在未使用完之前,别的执行流不能强行剥夺。
循环等待条件:就如上面例子所说的,一个执行流持有资源A,但想请求资源B,一个执行流持有资源B,但想请求资源A,形成环路。
那么应该如何避免发生死锁呢?
我们可以使用资源有序分配法来避免死锁。
就是当线程A和线程B都需要用到资源A和资源B的时候,线程A和线程B的申请资源顺序要相同,即线程A先获取资源A,后获取资源B,线程B也要同样的先获取资源A,后获取资源B。