下面这张图就是进程看待内存的方式(地址空间为64位):
这是进程看待内存的方式,所以我们虚拟地址和实际数据存储的地址是不同的,那么如何实现虚拟地址到物理地址的转换?
如果直接将整个程序从磁盘加载到内存中:首先会遇到内存可能不够的问题,其次多个进程同时被加载到内存时会产生碎片问题。这两个问题会严重影响程序的运行。解决方法是局部性!
首先我们会对物理内存分页,一页大小一般是4KB,分成若干个页,每个页有唯一的索引。每一个页叫做页框
记录上面页框的索引的表就叫做页表了!每条记录的索引叫做页表项,这里的索引可以是物理内存分页的地址也可以是页表地址!!!
线程总体来说就是一个执行流,是CPU调度的最小单位
说到线程不得不提他的爸爸进程,进程和线程是一个十分相关的概念。但是我们在下面的学习中始终要记住一下两点:
进程在学完线程之后实际上指的是所有执行流所共有的OS资源(PCB、内存),但是你创建进程必然会有一个执行流,一般把这个执行流叫做主线程!
这例做一个比喻:进程就像是一个房子,线程就像房子里面的家庭成员。
我们回忆一下OS是如何描述进程——PCB结构体!这个结构体里面有描述进程的所有信息(上下文、页表、寄存器)。
线程是一个进程的执行流之一,那么是否需要维护一个单独的结构体来描述线程呢?
windows操作系统其实为线程创建了单独的结构体,但是Linux操作系统却直接将描述线程的结构体复用进程的PCB结构体。本文将重点介绍Linux操作系统中的线程
在上面的地址空间是一个进程的地址空间,由于线程是进程的一个执行流,所以一个进程的线程地址空间应该与进程的地址空间理论上是一模一样。
如果我们重新为线程创建一个结构体,那么一个新的问题就出现了:
新的线程的结构体是有维护成本的,这无疑给CPU增加了很大的负担。而我们知道一个执行流在执行时顶多需要一些栈空间来维护临时变量,其他的地址空间对应的内存并不需要拷贝,直接所有执行流共用就可以了,所以Linux认为线程可以完全复用进程的PCB的地址空间,但是需要单独维护一下栈空间,并在PCB中建立一些标识位来标识线程即可。
这样做的好处
lwp(light weight process)是操作系统标识
进程切换:
线程切换
同时线程在执行的时候会在cache里面缓存很多热点数据,而这些数据在线程切换中并不会切出,所以线程间的切换要比进程间的切换速度要快很多。
我们可以输入ps -aL
来查看进程和线程,现在我们假设我们有一个test进程,他在执行的过程中创建了两个子进程,现在我们调用指令就可以看见:
注意
如果你想在Linux代码中创建一个线程。那么一定离不开pthread库(也可以使用C++11跨平台的线程库,后面我会专门开一个博客),pthread库实际上是对操作系提供的接口实现了再封装,这是为什么呢?
上面提到了,Linux中线程是复用进程的PCB,所以操作系统在调度的时候是不会区分你到底是线程还是进程,所以操作系统提供了一个接口clone
用来创建线程和进程:
你需要输入一大堆参数来控制这个进程/线程 具体如何实现,这个需要你对OS底层非常了解,但是我只是想创建一个线程这个结果,所以封装成了一个第三方库供用户创建线程时更加方便
下面就让我们了解一下pthread库
在正式了解之前还是要提一下如果要使用pthread库需要包含头文件:
#include
其次就是由于pthread是第三方库,所以在编译的时候要加上-lpthread
这个是pthread库用来标识线程的ID,它的数值不同代表线程不同。但是他与我们上面的LWP并不是很一样。
pthread库是一个动态库,所以它是运行时加载到进程/线程地址空间堆栈中间的共享区,在pthread的动态库里面会为你开辟的线程创建一个结构体来维护线程的私有结构,而这里标识每一个线程的标识符就是这些结构体在地址空间中的地址。
注意:主线程(main函数)的栈使用的是地址空间的栈,不用在动态库中创建私有栈
我们可以做一个实验来证明这个:
void *fun_test3(void *)
{
}
int main()
{
pthread_t t1, t2;
pthread_create(&t1, nullptr, fun_test3, nullptr); //创建线程1
pthread_create(&t2, nullptr, fun_test3, nullptr); //创建线程2
printf("0x%x\n", t1); //将t1用16进制打印出来
printf("0x%x\n", t2);
pthread_join(t1, nullptr); //等待线程1
pthread_join(t2, nullptr); //等待线程2
return 0;
}
thread
:返回线程ID(输出型参数) attr
:设置线程的属性(输入nullptr
为设置成默认属性)start_routine
:函数指针 返回值和参数都为void*
,线程启动之后要执行的函数arg
:传入start_routine的函数参数,也就是向线程传递的参数pthread_self
返回当前线程的pthread_t
类型的线程ID,与pthread_create
中第一个参数一样
线程退出的三种方式:
pthread_exit
函数退出当前线程pthread_cancel
函数来退出指定线程pthread_exit
retval
:是线程的返回值pthread_cancel
进程退出是依据pid来退出的,所以pid相同的线程都会退出。
假如我们某个线程调用了进程退出函数exit
函数,则所有线程都会退出。
但是线程退出就不一样了,来看下面一种情况:
void *fun_test3(void *)
{
// exit(1);
while (1)
{
cout << "hello " << endl;
}
}
void test3() // 博客 测试代码
{
pthread_t t3 = pthread_self();
pthread_t t1, t2;
pthread_create(&t1, nullptr, fun_test3, nullptr);
pthread_create(&t2, nullptr, fun_test3, nullptr);
pthread_exit(nullptr); //退出主线程
// return; //下面两种方法不能使用因为是退出进程
// exit(1);
pthread_join(t1, nullptr);
pthread_join(t2, nullptr);
return;
}
主线程创建了两个子线程,在子线程运行未结束的时候退出。我们在执行的时候打开监事窗口:
主线程退出后会进入僵尸状态,等待子线程退出并回收
pthread_join
thread
:为等待线程的IDretval
:当前线程拿到子线程的返回值为什么第二个参数是void
这里的是一个输出型参数,输出的是一个void *
指针,但是输出型参数必须传入地址,所以理所应当应该传入void **
pthread_detach
一个线程默认是joinable,那么什么是joinable呢?
joinable就是可以被回收的意思,如果一个线程是joinable那么创建他的父线程必须回收他(也就是拿到线程运行的结果)。但是有些线程我们并不关心她的运行结果,那就可以分离该线程。
pthread_detach(pthread_self());
此为分离当前线程
但是注意如果主线程已经join了某个子线程,那么这个子线程即使detach
也是没有用的,例如:
void *fun_test3(void *)
{
sleep(3);
pthread_detach(pthread_self());
int *p = new int[5]{1, 2, 3, 4, 5};
return (void *)p;
}
int main() // 博客 测试代码
{
pthread_t t3 = pthread_self();
pthread_t t1, t2;
pthread_create(&t1, nullptr, fun_test3, nullptr);
void *ret = nullptr;
pthread_join(t1, &ret);
for (int i = 0; i < 5; i++)
{
cout << ((int *)ret)[i] << endl;
}
return 0;
}
结果如下:
虽然子线程分离了自己,但是父线程在此之前join了子线程,所以退出后依然拿到了子线程的结果!
而如果子线程先分离,那么父线程在join就没有用了
线程虽然共享的空间变多了,但是随之而来的问题就是如何管理这些公共资源?
我没来看一个抢票的逻辑:
#include "Thread.hpp"
#include
#include
#include
#include
int ticket_number = 1000; //设置了1000张票
using namespace std;
void *getticket(void *args)
{
while (true)
{
if (ticket_number > 0)
{
usleep(9000);
cout << (const char *)args << " 剩余票数:" << ticket_number << endl;
--ticket_number;
}
else
break;
}
return 0;
}
int main()
{
thread t[10];
for (int i = 0; i < 10; i++) //设置10个线程同时抢票
{
char *p = new char[64];
snprintf(p, 64, "线程编号:%d 正在抢票", i + 1);
thread tmp(getticket, (void *)p);
t[i] = move(tmp);
}
for (int i = 0; i < 10; i++)
{
t[i].join();
}
return 0;
}
居然把票数抢成了负数,我们把票数定义成了一个全局对象,所有线程都可以访问是一个公共资源,但是这时也就会出现所谓的线程安全问题
为什么会造成上述现象?
假设我们现在只有一张票,这时线程1进入判断并开始休眠,此时OS将线程1挂起,切换别的线程
由于线程1并未对票数做操作,线程2依然可以进入判断并开始休眠
直到线程1休眠的时间到被唤醒,这时他醒来判断里面居然有一堆线程在休眠,这时已经完蛋了,票只有一张但是减减n次,所以就会被减成负数
造成线程安全的主要原因就是多个线程同时访问了同一个公共资源。所以我们必须让同时访问同一个公共资源的所有线程有序的进行访问,否则就会造成线程安全的问题。而我们的解决方法就是加锁
加锁
加锁就是给临界资源上了一把锁,要想访问临界资源必须向操作系统申请一把钥匙,一个锁对应一把钥匙,要想访问这个公共资源就必须有钥匙,每个线程拿到钥匙进入临界资源之后反手就把门锁上,访问完临界资源之后把锁交给操作系统。由操作系统再次重新分配
一种设计锁的思路:
设置一个变量int lock
如果lock ==1
就代表锁可以被申请,如果lock==0
代表锁已经被申请走了,申请锁的逻辑为:
运算符--
的在汇编上分为三步:
首先线程1执行--
操作,但是线程1执行完上述三步第一步之后,突然时间片到了触发中断,被操作系统挂起——具体操作就是CPU里寄存器的数据(上下文)都会存储到线程1的PCB中
这时线程2上来执行,他将内存中的数值拷贝到寄存器,先进行判断,符合条件。到这一步就已经证明这种方法的错误了,因为有两个线程同时在访问临界区的代码中了,这样就可能发生线程安全问题。
要想实现锁的原子性就必须保证加锁的动作是一步汇编,否则加锁的过程中线程切换就会造成线程安全问题
那么加锁的动作实际上是使用了swap
指令
swap
汇编指令将锁中的值1换到寄存器中,同时寄存器中的0被还如内存中的锁上swap
指令和内存中的锁进行交换注意:
我们发现哪个线程持有锁,实际上就是该线程的寄存器中存储着锁,那么当持有锁的线程在访问临界区代码时候被CPU切走会有线程安全问题吗?
不会!,线程被切走的时候上下文(也就是寄存器中的值)会被存起来,通俗的说也就是线程抱着锁休眠了。别的线程依然无法申请到锁。
死锁的四个必要条件
OK,那么死锁问题怎么解决呢?只要破坏四个必要条件中的一个即可将死锁破坏了
复习一下概念:
线程互斥: 指的是让线程按照串行的顺序去执行临界区域
引入一个概念:
线程同步: 是让线程按照一定的顺序去访问临界资源 why?—— 因为可能一个线程竞争锁的能力太强了,别的线程都抢不过他,会导致其他线程一直申请不到锁也就访问不到临界资源(线程的饥饿问题)
条件变量创建
int pthread_cond_init (pthread_cond_t *__restrict __cond , const pthread_condattr_t *__restrict __cond_attr)
__restrict __cond
:是一个pthread_cond_t
类型变量,改变了是条件变量的类型__restrict __cond_attr
:设置条件变量的属性,一般填的都是nullptr
等待条件满足
int pthread_cond_wait (pthread_cond_t *__restrict __cond , pthread_mutex_t *__restrict __mutex)
__restrict __cond
:指明条件变量,哪个线程调用这个函数就会去名为__restrict __cond
的条件变量下等待__restrict __mutex
:线程等待之前必须把锁交了,否则就会造成死锁问题在线程被唤醒之后会接着该函数之后继续执行(可能依然在临界区内),所以该函数依然会贴心的位该线程申请锁
唤醒等待
int pthread_cond_broadcast (pthread_cond_t *__cond)
__cond
条件变量下等待的线程int pthread_cond_signal (pthread_cond_t *__cond)
__cond
条件变量下等待的线程那么如何使线程们“有序” 的访问临界资源呢?
实际上的条件变量都是这个逻辑
这样我们就达成了一个顺序的执行
我们要来在计算机的角度来审视一下这个模型:
关于生产者和消费者我们可以用“三二一”来记忆
阻塞队列是一个任务队列(固定大小),生产者/消费者 互斥的向队列 放入任务/拿取任务执行,并使用条件变量来完成 生产者-生产者 / 消费者-消费者 的同步关系,但是唯一要注意的是 生产者的条件变量队列的唤醒条件的决定权是在消费者手上,同理 消费者的条件变量队列的唤醒条件的决定权是在生产者手上。
再看条件变量
我们先前像抢票模型,访问的逻辑都是申请锁、判断、执行逻辑、释放锁。但是如果判断条件不满足 就变成了申请锁、条件判断、释放锁。这样的空转实际上是资源的浪费
举个例子:超市里面没有货物,消费者在门口排起了长队(条件变量的队列),如果每个消费者都进去看看消费者有没有送货是一个非常低效的选择,高效的解决方法是消费者就在门口等待,生产者生产好货物送上超市的货架并通知消费者消费(pthread_cond_singal)。这也解释了上面生产者的条件变量队列的唤醒条件的决定权是在消费者手上,同理 消费者的条件变量队列的唤醒条件的决定权是在生产者手上。
代码
#pragma once
#include
#include
#include
#include
#include
#define NUM 10
template <class T, size_t N = NUM>
class Blockqueue
{
public:
Blockqueue()
{
pthread_cond_init(&_p_cond, nullptr);
pthread_cond_init(&_c_cond, nullptr);
pthread_mutex_init(&_mutex, nullptr);
}
void push(const T &x)
{
pthread_mutex_lock(&_mutex);
while (is_full()) // 如果队列满了就进入该循环进行挂起
{
pthread_cond_wait(&_p_cond, &_mutex);
}
// 走到这里代表队列一定不是满的
_q.push(x);
// 走到这里代表队列里一定有元素,可以唤醒消费者
pthread_cond_signal(&_c_cond);
pthread_mutex_unlock(&_mutex);
}
void pop(T *x)
{
pthread_mutex_lock(&_mutex);
while (is_empty()) // 如果队列为空 执行此逻辑
{
pthread_cond_wait(&_c_cond, &_mutex);
}
T top = _q.front();
// 消费数据
//....
*x = top;
_q.pop();
// 走到这里队列一定不是满的可以通知生产者继续生产
pthread_cond_signal(&_p_cond);
pthread_mutex_unlock(&_mutex);
}
bool is_full() // 判断是否满了
{
return (_q.size() == N);
}
bool is_empty() // 判断是否为空
{
return _q.empty();
}
private:
std::queue<T> _q;
pthread_cond_t _p_cond;
pthread_cond_t _c_cond;
pthread_mutex_t _mutex;
};
阻塞队列的缺点: 实际上生产者和消费者在访问阻塞队列的时候还是一种互斥关系,有没有一种方法能完成生产者和消费者的解耦——生产者、消费者可以同时访问队列
我们的做法也非常简单:因为我们发现每次消费者和生产者都是队列中的一个元素,只有生产者和消费者访问的不是一个元素就不会出现线程安全问题。阻塞队列的做法是给整个队列加上锁,而我们可以把临界资源切成若干份,只要生产者和消费者不同时访问同一个小份就不会出现线程安全问题!
这里我们就要引入信号量
信号量的本质: 我们把一个临界资源切成若干个小份,而信号量实际上就是一个计数器,用来代表这些可用的小份的个数。所以信号量本质是一个计数器!!!
注意
初始化信号量
sem
:信号量的名称pshared
:信号量是否被共享value
:信号量设置的计数器的值信号量的PV操作
那么环形队列是怎么用信号量来实现
下面是一个比较简单的环形队列,生产者和消费者都有一个指针:
但是这样的环形队列有一个问题:
队列满的时候和队列空的时候,两个指正会指向同一块区域。我们并不能很好的区分这两种情况。但是引入信号量之后就不会出现问题。我们用一个信号量来表示 生产任务的个数 ,另一个信号量来表示 剩余空间大小。不论是你是消费者还是生产者你上来不管三七二十一先申请对应的信号量,生产者申请剩余空间的信号量,消费者申请任务数量的信号量。如果能申请到才加锁访问空间
#include
#include
#include
#include
#include
// 要搞清楚 环形队列和阻塞队列 到底在哪里不同
// 阻塞队列 是 先加锁 在 进入临界资源进行判断,这样如果队列满了,碰巧这一短时间都是生产者线程竞争成功
// 那么CPU 就会一直在做无用功(加锁、判断不满足、进入条件变量下等待并解锁)
// 而环形队列 在生成对象之就对临界资源进行了划分 这样临界资源的粒度更小,生产者和消费者只要不是访问同一个
// 小块就不会出现线程安全问题,这样就可以实现生产者和消费者同时访问临界资源;而这一点是阻塞队列无法达到的
namespace sht
{
template <class T, size_t N>
class RingQueue
{
public:
RingQueue(int sz = 0)
: p_cur(0), c_cur(0)
{
// 初始化信号量
std::cout << N << std::endl;
_array.resize(N);
sem_init(&p_sem, 0, 0);
sem_init(&c_sem, 0, N);
pthread_mutex_init(&p_mutex, nullptr);
pthread_mutex_init(&c_mutex, nullptr);
}
// 对PV操作进行封装
// P操作:申请资源 == 计数器--
void P(sem_t &x)
{
// 阻塞式申请
int ret = sem_wait(&x);
}
// V操作:释放资源 == 计数器++
void V(sem_t &x)
{
int ret = sem_post(&x);
}
void push(T &x)
{
P(c_sem);
// 为什么这里要加锁? ——申请到信号量只代表队列里面有空间,但是哪个空间有可能会冲突
pthread_mutex_lock(&p_mutex);
_array[p_cur++] = &x;
p_cur %= N;
pthread_mutex_unlock(&p_mutex);
V(p_sem);
}
void pop(T **x)
{
P(p_sem);
pthread_mutex_lock(&c_mutex);
*x = _array[c_cur++];
c_cur %= N;
pthread_mutex_unlock(&c_mutex);
V(c_sem);
}
private:
std::vector<T *> _array;
sem_t p_sem; // 队列中已经被占有的空间
sem_t c_sem; // 队列中空余的空间
int p_cur;
int c_cur;
pthread_mutex_t p_mutex; // 生产者的锁
pthread_mutex_t c_mutex; // 消费者的锁
};
}