Linux——线程

很楠不爱 2024-10-26 12:37:02 阅读 54

OS进行内存管理,不是以字节为单位,而是以内存块为单位的,默认大小为4KB,称为页框,由OS统一管理。

这与系统与磁盘进行文件IO的基本单位——8个扇区的大小相同

所以磁盘中的文件再被调用时,都是按照4KB的大小被直接加载到内存空间中的

对于32位虚拟地址,其中其前10位,代表页目录,共有1024项,是一个索引,每一项代表一个页表的地址;中间10位,为页表,也有1024项,每一项代表一个页框的起始地址;后12位,称为页内偏移量,通过页框的起始地址,加上页内偏移量,就可以找到物理内存的任意一个地址


一.基本概念

线程:在进程内部运行,是CPU调度的基本单位。

所谓线程,是在进程内部,拥有自己独立的task_struct的若干个执行流,但是和进程共用一套虚拟地址空间和页表,进程将自己的代码进行划分,分别交给不同的线程去执行

在此背景下,我们在内核层面重新定义进程的概念:

进程:承担分配系统资源的基本实体

线程同样拥有类似于进程PCB的数据结构,称为TCB,实际上,在Linux系统中,线程复用了进程的PCB,这样就可以用PCB统一表示执行流,从而不需要为线程单独设计数据结构和调度算法

因此,对比线程和进程,其实都属于同一种概念,区别在于,进程内部只有一个执行流,线程则是进程内的多个执行流


二.线程理解

线程相较于进程的调度成本更低

这是因为在CPU中,存在一个cache的存储器,它可以将程序的代码和数据从内存中暂时缓存到CPU中,此时当CPU需要访问代码和数据时,就可以先去cache中寻找,找不到再去内存中获取,大大减少了OS在CPU与内存之间的切换次数。

对于进程而言,如果切换进程,由于不同进程拥有不同的代码和数据,那么cache中加载的数据就无用了,还需要进行重新加载;而线程之间共用同一份代码和数据,此时切换线程,cache中的代码和数据就仍然可用

多线程中,有一个线程出现异常,所有的其他线程都会被终止,即整个进程被终止。

多线程可以共享地址空间上的大部分资源。

线程中私有的部分:

一组寄存器:包含硬件的上下文数据,说明线程可以动态运行。栈:线程在运行的时候,会形成各种临时变量,临时变量会被每个线程保存在自己的栈区。线程IDerrno信号屏蔽字调度优先级


三.线程控制

pthread库,是Linux自带的原生线程库,对轻量级进程接口进行封装,按照线程的接口方式,交给用户,因此在使用线程相关调用接口时,需在编译时链接pthread库

1.线程创建

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);

参数:

thread:输出型参数,线程id。

attr:线程属性。不做处理一般设为nullptr。

start_routine:创建的新线程要执行的函数的指针。

arg:作为新线程要执行的函数start_routine的参数进行传递。

返回值:

创建成功返回0,失败返回错误码。

当一个进程中创建了新线程时,就没有了进程的概念,而是称为主线程和新线程,主线程继续向后执行main函数代码,新线程则执行对应函数的代码。 

通过ps -aL指令,可以查看当前路径下的所有线程,其中LWP:轻量级进程ID。

Linux线程 = pthread库中线程的属性集 + LWP。

全面看待线程函数的传参和返回值void*

只考虑正确的返回,不考虑异常,因为一旦发生异常,整个进程就崩溃了,包括主线程。参数和返回值都可以是任意类型,包括类对象的地址。

#include <pthread.h>

pthread_t pthread_selp(void);

线程获取自己ID的函数,在线程内部使用。


2.线程等待

线程等待通常是由主线程来等待新线程,其作用是让主线程等待接收新线程的退出情况,使主线程能够最后退出。

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);

参数:

thread:要等待的线程的ID。

retval:输出型参数,得到新线程执行的函数的返回值,不使用则置为nullptr。

返回值:

创建成功返回0,失败返回错误码。

当一个线程被创建,默认是joinable的,即必须被join的。 

当然,我们也可以不使用上述函数去等待新线程结束,反而去将线程进行分离。

如果一个线程被分离,线程的工作状态就变为分离状态,不需要也不能被join。

线程分离函数:

#include <pthread.h>

int pthread_detach(pthread_t thread);

当一个线程被设置为分离状态后,它结束时,系统会自动回收其资源,而不需要主线程进行join等资源回收操作。

但是线程分离并不是完全性的,当分离后的线程出现异常时,仍会导致整个进程崩溃。


3.线程终止

线程终止包括一下几种方式:

最常规的函数return。

线程终止函数:

#include <pthread.h>

void pthread_exit(void *retval);

参数:

线程执行函数的参数。

在线程内部调用。

线程取消函数:

#include <pthread.h>

int pthread_cancel(pthread_t thread);

参数:

线程ID。

在主线程中调用,取消成功时,线程函数返回-1.


四.线程ID理解

在OS中,我们想要使用线程库,需要先将库从磁盘中加载到内存,在通过页表将库从内存中映射到地址空间的堆栈共享区中,而我们所创建的所有的线程,实际上都要被库进行管理。

在行线程库中,存在一个一个的数据块,数据块中包含着线程的各种属性信息及其单独的存储空间和栈空间,由线程库统一管理,而这些线程数据块的起始地址,就是线程的ID,因此,线程的ID实际上是虚拟地址空间中的虚拟地址


五.线程互斥

默认情况下,所有的线程会共享代码中的全局变量,但是如果全局变量被__thread修饰,那么该全局变量就会为所有的线程单独创建一份,这叫做线程的局部存储。 

局部存储只在Linux系统下有效,并且只能修饰内置类型。

多个线程能够看到的资源即为共享资源,需要对这些资源进行保护,此时就需要线程之间互斥

 我们实现了一个多个线程同时抢票的代码,但是却发现最后抢到的票竟然会有0和负数

void route(const string &name)

{

while(true)

{

if(tickets > 0)

{

//抢票过程

usleep(1000);//1ms->抢票花费的时间

printf("who: %s, get a ticket:%d\n",name.c_str(),tickets--);

}

else

break;

}

}

这是什么原因导致的呢???

我们需要了解一点,内存中的数据tickets在进行算数运算或逻辑运算时,是需要加载进CPU的寄存器中进行的,在CPU内,寄存器只有一套,但是寄存器中的数据,可以同时被多个线程拥有,并且每个线程拿到的数据都是自己私有的。

当我们的tickets只剩1张时,此时如果四个线程都同时运行并拿到了tickets = 1的数据,他们都能通过if判断进而会执行抢票过程,这就是为何当tickets为0时,后续线程仍会执行抢票的原因。

CPU执行线程时,是会发生线程切换的,如果此时一个线程还未运行完毕就被切换,就会保存其自己的上下文数据,进而由另一个线程去运行,此外,在执行--操作时,线程是需要重新获取到tickets的值,在执行--操作,而不是继续使用自己保存的上下文数据中的tickets,因此,当四个线程同时执行,前边的线程已经将tickets减为0,后续线程继续减,进而就得到了负数。

那么为了避免该问题的发生,我们就需要给资源加锁。 


1.锁及其接口

互斥锁:任何时刻,只允许一个线程进行资源访问。

互斥锁类型为:pthread_mutex_t.

#include<pthread.h>

//初始化锁

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

参数:

mutex:定义的锁。

attr:锁的属性,不使用设为nullptr。

//初始化锁

pthread_mutex_t *mutex = PTHREAD_MUTEX_INITIALIZER;

当锁是全局的或者是静态的,只需要上述初始化即可且,无需调用init函数,且不需要销毁,其余锁必须调用init函数进行初始化并进行销毁。

//销毁锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

//加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

//解锁 

int pthread_mutex_unlock(pthread_mutex_t *mutex);

对临界资源进行保护,本质是对临界区代码进行保护。

保护资源,本质就是想办法把访问资源的代码保护起来

锁本身也是共享资源,因此加锁的过程,必须是原子的。

原子性:要么不做,要么就做完,没有中间状态

如果线程申请锁失败了,线程要被阻塞,等待解除阻塞。

如果线程申请锁成功了,则会继续向后运行

如果线程申请锁成功了,执行临界区的代码了,执行临界区代码期间,是可以被切换的,但是其他线程仍然无法进入,因为锁并没有被释放,原线程可以放心的执行完毕

CPU的寄存器只有一套,被所有线程共享,但寄存器里的数据,属于执行流的上下文,属于执行流私有的数据。

CPU在执行代码时,一定要有对应的执行载体,即线程或进程

把数据从内存移动到CPU寄存器中,本质是把数据从共享,变成线程私有


六.线程同步

前边我们给多个线程加锁,保证了线程能够正确不越界的获取资源,但是每个线程获取到的资源数却大不相同,甚至可能出现某一个线程抢占了一大半资源的情况,从而导致其他线程长时间无法获取资源而出现饥饿问题。

因此我们希望多个线程能够平均的抢占资源,即线程需要合理的申请资源,需要有严格的顺序性,也可以是宏观上具有相对的顺序性,这就是线程同步。


1.条件变量

#include<pthread.h>

//初始化条件变量

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);

参数:

cond:定义的条件变量。

attr:条件变量的属性,不使用设为nullptr。

//初始化条件变量

pthread_cond_t *cond = PTHREAD_COND_INITIALIZER;

条件变量的用法与锁完全相同。

//销毁条件变量

int pthread_cond_destroy(pthread_cond_t *cond);

//等待条件变量

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);

等待函数用于临界区中,当其被调用时,除了使线程排队等待之外,还会释放自己传入的锁,而在其返回的时候,因为还在临界区中,所以必须先参与锁的竞争,重新加锁之后才会返回。

//唤醒一个线程

int pthread_cond_signal(pthread_cond_t *cond);

//唤醒全部线程

int pthread_cond_broadcast(pthread_cond_t *cond);

定义出条件变量之后,线程获取到锁之后,需要去等待条件变量,在等待期间,线程会被阻塞,等待主线程去执行唤醒函数来唤醒线程,线程被唤醒后便可获取到条件变量,进而获取资源。 


2.生产消费模型

生产消费模型是一种多执行流并发的模型,其优点在于协调忙闲不均,效率高,解耦。

“321”原则

一个交易场所(特定数据结构形式存在的一段内存空间)两种角色(生产角色,消费角色)生产线程,消费线程三种关系(生产和生产,消费和消费,生产和消费)

实现生产消费模型,本质就是通过代码,实现321原则,用锁和条件变量(或者其他方式)来实现三种关系。


3.信号量

线程部分的信号量,同进程部分的信号量一致,信号量本质是一个计数器,用来表示被申请的公共资源的数量,申请信号量的本质就是对公共资源的一种预定机制。

#include <semaphore.h>

//初始化信号量

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数:

sem:定义的信号量。

pshared:信号量是否与其他进程中的线程共享,不需要设置为0。

value:信号量的数量。

//销毁信号量

int sem_destroy(sem_t *sem);

//申请/等待信号量

int sem_wait(sem_t *sem);

当信号量充足时,调用该函数,即帮助线程拿到一个公共资源,同时信号量会-1;当信号量不足时,该函数就会阻塞。

//归还信号量

int sem_post(sem_t *sem);


七.单例模式

某些类,只应该具有一个对象(实例),就称之为单例。

单例模式有两种实现方式,饿汉实现方式和懒汉实现方式,两者的区别在于:

饿汉:直接创建一个对象,随时都可以使用。懒汉:先不创建对象,需要使用时再创建。

饿汉方式实现单例模式

template <typename T>

class Singleton

{

        static T data;

public:

        static T* GetInstance()

        {

                return &data;

        }

};

懒汉方式实现单例模式

template <typename T>

class Singleton

{

        static T* inst;

public:

        static T* GetInstance()

        {

                if (inst == NULL)

                {

                        inst = new T();

                }

                return inst;

        }

};


八.扩展

1.可重入vs线程安全

线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。

重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。


(1)可重入与线程安全联系

函数是可重入的,那就是线程安全的。函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

(2)可重入与线程安全区别

可重入函数是线程安全函数的一种。线程安全不一定是可重入的,而可重入函数则一定是线程安全的。 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。


2.死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。


(1)死锁四个必要条件

互斥条件:一个资源每次只能被一个执行流使用。请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。


(2)避免死锁

破坏死锁的四个必要条件 加锁顺序一致 避免锁未释放的场景 资源一次性分配




声明

本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。