线程概念
线程是进程内部执行,是OS调度的基本单位。将进程资源合理分配给每个执行流,就形成了线程执行流。一个进程内部至少有一个执行线程
堆区中有一个vm_area_struct,该结构体有start和end,再通过页表映射到内存中
可执行程序,其实是被划分为4kb为单位的区域,这个区域叫做页帧,而物理内存也被分为4kb大小,叫做页框。所以IO的基本单位是4kb,就是将页帧装进页框里。
用struct page对页帧进行管理,所以对物理结构的管理就变成了对特定数据结构的管理。
缺页中断:当OS通过页表进行寻址时,发现对应的内存区域不在内存中,就引发缺页中断。先确认申请的内存,再磁盘中找到要加载的目标数据对应的地址,将目标数据的内容加载到内存指定的位置,再重新填充页表,再返回用户进行访问。此过程用户不知情,是完全透明的。
32位的虚拟地址分为10、10、12三部分,并不是整体使用的。页表不是一个简单的结构,是多级的;一级页表只用前十位进行索引,所以一级页表需要建立2^10个映射关系用来匹配到二级页表,再用第二部分的十位进行检索,映射到要访问的页在物理内存当中的起始地址。后12位就是页内偏移地址,起始地址+偏移地址就可以找到物理地址。
线程的优点
- 创建线程比进程容易
- 线程切换容易
- 占用的资源少
- 能充分利用多处理器的可并行数列
- 在等待慢速IO时可以执行其他的任务
- 可以将计算分解到多个线程中实现
- 线程可以同时等待不同的IO
线程的缺点
- 性能损失
- 健壮性降低
- 缺乏访问控制
- 编程难度提高
如何理解线程
线程在进程的地址空间内运行,CPU其实不关心执行流是进程还是线程,只关心PCB。在Linux中,每一个PCB都可称为线程。
进程在用户视角就是内核数据结构(可以有多个PCB)+该进程对应的代码和数据。在内核视角来看,就是承担分配系统资源的基本实体。PCB就是进程内部的一个执行流。
CPU调度的基本单位是线程。在Linux下,PCB的量级更轻,所以它的进程叫做轻量级进程。Linux没有真正意义上的线程结构,是用进程PCB模拟的。linux不能直接给我们提供相关的接口,只能提供轻量级进程的接口。在用户层实现了一套用户多线程方案,以库的方式提供给用户进行使用,叫做原生线程库(pthread)。
pthread
参数:线程id,线程属性(默认nullptr就行),函数指针,传给函数指针的参数,完成一个回调的过程。
int x = 100;
void show(const string &name)
{
cout << name << ", pid: " << getpid() << " " << x << "\n"
<< endl;
}
// 新线程
void *threadRun(void *args)
{
const string name = (char *)args;
while (true)
{
show(name);
sleep(1);
}
}
int main()
{
pthread_t tid[5];
char name[64];
for (int i = 0; i < 5; i++)
{
// 格式化控制
snprintf(name, sizeof name, "%s-%d", "thread", i);
// 创建线程
pthread_create(tid + i, nullptr, threadRun, (void *)name);
sleep(1); // 缓解传参的bug
}
// 主线程
while (true)
{
cout << "main thread, pid: " << getpid() << endl;
sleep(3);
}
}
// 编译时需要加-lpthread
ps -aL | head -1 && ps -aL | grep mythread
// 查看线程的命令
可以看到有六个线程执行流,而且pid都相同,说明他们都在同一个进程中运行。LWP是轻量级进程对应的PID,其编号不一样。主线程的PID和LWP是相同的。
线程如何看待进程内部的资源呢?
共享一部分资源和环境:文件描述符、信号的处理方式、当前工作目录、用户id和组id、堆
私有:线程id、寄存器、栈、errno、信号屏蔽字、调度优先级
为什么线程切换成本低?地址空间和页表不需要切换。CPU内部有各种cache(缓存),对内存的代码和数据,根据局部性原理预读到cpu内部。如果进程切换,cache就立即失效,新进程过来就只能重新缓存。
线程异常
线程出问题,进程也就出现异常,进而会终止进程
线程控制
线程创建
void *threadRoutine(void *args)
{
pthread_detach(pthread_self());
while(true)
{
cout << (char*)args << " running " << endl;
sleep(1);
int a = 10;
a /= 0;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRoutine,(void*)"thread 1");
while(true)
{
cout << "main线程:"<<"running……"<<endl;
sleep(1);
}
return 0;
}
线程谁先运行和调度器有关,线程一旦异常,都可能导致整个进程退出。线程在创建并执行的时候,线程也是需要等待的,如果主线程不等待,会引起类似进程的僵尸问题,导致内存泄漏。
线程等待
void *threadRoutine(void *args)
{
int i = 0;
pthread_detach(pthread_self());
while(true)
{
cout << (char*)args << " running " << endl;
sleep(1);
if(i++==10) break;
}
// pthread_exit((void*)10); 线程退出方式,并且主线程可以接受打印。如果用exit则不会打印
cout << "new thread exit" << endl;
// return (void*)10;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,threadRoutine,(void*)"thread 1");
// 可以将新线程的返回值设置并接受
// int *ret = nullptr;
// pthread_join(tid, (void**)&ret);
// cout << "new thread quit:"<<(long long)ret<<endl;
pthread_join(tid, nullptr);
// 默认会阻塞等待新线程退出,只有新线程退出才会继续执行
cout << "main thread wait done ... main quit ...:\n";
sleep(5);
return 0;
}
该函数的功能是等待线程结束
为什么要线程等待? 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建心得线程不会复用刚才退出线程的地址空间。
在多线程中不要调用exit,它是终止进程的,可以使用pthread_exit
线程取消:在主线程中用pthread_cancel(tid);参数是线程ID
线程被取消,join的时候,退出码是-1,其实是PTHREAD_CANCELED;如果要取消,必须保证新线程存在并运行了一段时间;
pthread_join的参数问题
- 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值
- 如果被别的线程调用pthread_cancel,retval所指向的单元了存放的是常数PTHREAD_CANCELED
- 如果是自己调用pthread_exit,所指向的单元里存放的是传给pthread_exit的参数
- 如果对thread线程的终止状态不感兴趣,可以传nullptr给retval
线程终止
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void* thread1(void* arg)
{
printf("thread 1 returning ... \n");
int* p = (int*)malloc(sizeof(int));
*p = 1;
return (void*)p;
}
void* thread2(void* arg)
{
printf("thread 2 exiting ...\n");
int* p = (int*)malloc(sizeof(int));
*p = 2;
pthread_exit((void*)p);
}
void* thread3(void* arg)
{
while (1)
{
printf("thread 3 is running ...\n");
sleep(1);
}
return NULL;
}
int main(void)
{
pthread_t tid;
void* ret;
// thread 1 return
pthread_create(&tid, NULL, thread1, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// thread 2 exit
pthread_create(&tid, NULL, thread2, NULL);
pthread_join(tid, &ret);
printf("thread return, thread id %X, return code:%d\n", tid, *(int*)ret);
free(ret);
// thread 3 cancel by other
pthread_create(&tid, NULL, thread3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &ret);
if (ret == PTHREAD_CANCELED)
printf("thread return, thread id %X, return code:PTHREAD_CANCELED\n", tid);
else
printf("thread return, thread id %X, return code:NULL\n", tid);
}
线程id的本质就是这个线程在库内部对应的相关属性的集合的起始地址。pthread_self(),调用线程id,哪个线程中调用,就过去谁的
线程的局部存储
__thread int g_val = 0;
全局变量由所有线程共享
__thread : 修饰全局变量,就是让每一个线程各自拥有一个全局的变量(有两个_)
线程分离pthread_detach(pthread_t thread)
默认情况下,新创建的线程是joinable的,线程退出后,需要进行等待,否则无法释放资源,造成系统泄漏;如果不关心线程的返回值,我们就可以告诉系统,当线程退出时,自动释放资源。分离和等待不能同时存在。
线程互斥
相关概念
- 临界资源:一个资源在被宫格线程执行流共享的情况下,通过一定的方式,让任何时候只允许一个执行流访问的资源
- 临界区:访问临界资源的代码
- 互斥:保证只有一个执行流进入临界区,访问临界资源,对临界资源进行保护
- 原子性:不会被任何调度机制所影响,该操作只有完成与不做两态
互斥量
大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量属当个线程,其他线程无法获取这个变量,但是,很多变量都需要在线程间共享,这样的变量叫做共享变量,可以通过数据共享完成线程之间的交互,但是会带来一些问题,就引入了互斥锁。
接口
初始化
- 静态分配:可用于静态或全局
pthread_mutex_t mutex=PTHREAD_MUTEX_INITIALIZER
- 动态分配:用于局部
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
// attr:NULL
加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 成功返回0;失败返回错误号
任何时候,都只允许一个线程获取这个锁,没有拿到的只能阻塞等待。 只有将该线程进行解锁,才能够继续同时获取。枷锁和解锁之间的代码就叫做临界区,被串行访问的共享资源就叫做临界资源。执行临界区代码是串行的
#include <iostream>
#include <thread>
#include <cerrno>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
#include <cassert>
#include <cstdio>
using namespace std;
int tickets = 10000;
#define THREAD_NUM 800
class ThreadData
{
public:
ThreadData(const std::string &n,pthread_mutex_t *pm):tname(n), pmtx(pm)
{}
public:
std::string tname;
pthread_mutex_t *pmtx;
};
void *getTickets(void *args)
{
ThreadData *td = (ThreadData*)args;
while(true)
{
// 抢票逻辑
int n = pthread_mutex_lock(td->pmtx);
assert(n == 0);
// 临界区
if(tickets > 0) // 1. 判断的本质也是计算的一种
{
usleep(rand()%1500);
printf("%s: %d\n", td->tname.c_str(), tickets);
tickets--; // 2. 也可能出现问题
n = pthread_mutex_unlock(td->pmtx);
assert(n == 0);
}
else{
n = pthread_mutex_unlock(td->pmtx);
assert(n == 0);
break;
}
// 抢完票,其实还需要后续的动作
usleep(rand()%2000);
}
delete td;
return nullptr;
}
int main()
{
pthread_mutex_t mtx;
pthread_mutex_init(&mtx, nullptr);
srand((unsigned long)time(nullptr) ^ getpid() ^ 0x147);
pthread_t t[THREAD_NUM];
// 多线程抢票的逻辑
for(int i = 0; i < THREAD_NUM; i++)
{
std::string name = "thread ";
name += std::to_string(i+1);
ThreadData *td = new ThreadData(name, &mtx);
pthread_create(t + i, nullptr, getTickets, (void*)td);
}
for(int i = 0; i < THREAD_NUM; i++)
{
pthread_join(t[i], nullptr);
}
pthread_mutex_destroy(&mtx);
}
加了锁之后,线程在临界区中,会切换,虽然被切换了,但是你是持有锁被切换的,所以其他抢票线程要执行临界区代码,也必须先申请锁,但无法申成功,所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性。在没有持有锁的线程看来,对我最有意义的情况只有两种:1、线程1没有锁(不做) 2、线程1释放锁(做完),此时我可以申请锁,所以线程1就是原子的。要访问临界资源,每一个线程都必须申请锁,每一个线程都必须先看到同一把锁,也就是说锁是共享资源,。所以为了保证锁的安全,申请和释放锁,必须是原子的。
为了实现互斥锁操作,提供了swap和exchange指令,该指令的作用是把寄存器和内存单元的数据象交换,由于只有一条指令,保证了原子性。在汇编的角度,只有一条语句,就认为是原子的。
寄存器本身是被所有线程共享,但是寄存器的内容,是每一个执行流私有的,本质就是当前执行流的上下文数据。申请锁的过程就是一个交换的过程。
可重入和线程安全
线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数
函数可重入,则线程安全;线程安全,该函数不一定是可重入;如果一个函数由全局变量,那么这个函数既不是线程安全也不是可重入
死锁
指一组进程中的各个线程均占有不会释放的资源,但因相互申请被其他线程锁占用不会释放的资源而处于一种永久等待的状态。
死锁四个必要条件
- 互斥条件:一个资源每次只能被一个执行流使用
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
- 不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
避免死锁
- 破坏死锁的四个必要条件
- 加锁顺序一致
- 避免锁未释放的场景
- 资源一次性分配
线程同步
饥饿:某一个线程长时间得不到某一个资源。同步就是解决访问临界资源合理性问题的
同步就是按照一定的顺序,进行临界资源的访问
方法一:条件变量
当我们申请临界资源前,先要检查临界资源是否存在,本质也是访问临资源。所以,对临界资源的检测,也是在加锁和解锁之间的。常规方式检测条件就绪,就注定了要频繁申请和解锁。
// 条件变量函数
// 初始化
int pthread_cond_init(pthread_cont_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 要在这个条件变量上等待
// 唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
wait代码被执行,该线程会立即被阻塞,wait一定要在加锁和解锁之间。第二个参数是锁,当成功调用之后,传入的锁会自动释放。从哪里阻塞,就从哪里唤醒,并且自动帮助线程获取锁
生产消费模型
生产者和消费者之间不直接通讯,而是通过阻塞队列来进行的,就相当于一个缓冲区,来给生产者和消费者进行解耦的。有3种生产关系,2种角色和1个交易场所。
POSIX信号量
POSIX和SystemV作用相同,都是用于同步操作,达到无冲突的访问共享资源目的,但是POSIX可以用于线程间同步
#include <semaphore.h>
// 初始化信号量
int sem_init(sem_t * sem,int pshared,unsigned int value);
// pshared:0表示线程间共享,非零表示进程间共享 value:信号量初始值
// 销毁信号量
int sem_destroy(sem_t *sem);
// 等待信号量
int sem_wait(sem_t *sem);
// 等待信号量,将信号量-1,P操作
// 发布信号量
int sem_post(sem_t *sem);
// 表示资源使用完毕,可以归还资源,将信号量的值+1,V操作
环形结构的生产消费模型
当生产者和消费者指向同一个位置时,使用互斥同步就可以;不指向同一个位置时,让他们并发执行。生产者不能将消费者套圈,消费者不能超过生产者。为空,要让生产者先行;为满,让消费者先行。其他情况可以并非访问。
生产者关注空间资源,消费者关注数据资源
信号量的本质就是计数器,可以不用进入临界区,就可以得知资源情况,甚至可以减少临界区内部的判断。
线程池
用空间换时间的做法。本质就是生产消费模型