【Linux】线程(thread)

版权声明:本文为博主原创文章,未经博主允许不得转载。Copyright (c) 2018, code farmer from sust. All rights reserved. https://blog.csdn.net/sustzc/article/details/82734479

线程创建的时候,会重新分配PCB,但是页目录以及页表是共享的。

Linux中实际上没有线程,而是用进程来模拟线程。
    进程是所有PCB组成的一个线程组。
Linux中的线程是轻量级进程。
    协程是轻量级线程,完全由程序控制,也就是在用户态执行。
    进程是具有一个或者多个执行流的线程组。
线程
    一个进程至少有一个执行线程。
    线程的执行粒度是比较细致的。(只执行进程的一部分内容)
    线程是在进程的虚拟内存空间中运行的。
    进程中的多个线程平分进程栈的大小。
为什么内核占1G,其余占3G?
    这是缺省的,实际上在内核优化时可以更改。
多线程与多进程的对比
    1.系统开销
    
多线程内部情况
    一对一模型
        缺点:1.用户的线程数量受到限制;
            2.线程调度时,上下文切换开销大,导致用户线程的执行效率下降。
    多对一模型
        缺点:当某个用户线程阻塞,那么所有的线程都将无法执行。
        优点:高效的上下文切换和无限制的用户线程数量。
    多对多模型:
        将多个用户线程映射到少数但不止一个内核线程上。
    
线程拥有自己的一部分数据:(线程的访问权限)
    线程标识符
    上下文数据(PC寄存器,程序计数器,栈指针)   面试重点  (线程切换,需要保护现场数据)
    自己的调用栈(私有栈)   面试重点   (为了避免临时变量相互之间不被干扰)   并非完全无法被其他线程访问。
    errno
    信号屏蔽字
    调度优先级
    线程的局部存储(TLS)  某些OS为线程单独提供的私有空间,通常是很有限的容量。

一个进程的多个线程共享
    同一地址空间(代码段,未初始化和初始化的数据段)    环境变量 以及 堆 、运行参数   文字代码区 函数 全局变量 静态变量
    文件描述符表
    每种信号的处理方式
    当前工作目录
    uid 和 gid

线程和进程的区别
    进程是OS分配资源的基本单位,线程是CPU调度的基本单位。
    线程创建和销毁的成本相较于进程,更低。(线程只需要销毁PCB即可,而进程需要页表,PCB,磁盘程序加载至物理内存等等)
        不仅仅是体现在成本上,实际在时间上也比较低。
    线程的调度成本更低。(线程切换时,并不需要切换页表,这是因为他们同属于一个进程,共享同一块虚拟内存空间。)
    线程之间的关系亲密(线程之间共享资源),而进程之间是相对独立的(进程之间独占资源)。
线程与线程之间共享的数据包括
    I/O状态信息,代码段,数据区,堆,初始化和未初始化全局变量
    记账信息,寄存器信息,栈,程序计数器,线程的信号屏蔽码,线程的优先级,错误返回码(errno),线程ID是私有的。
    
使用多线程而不使用单线程的原因?
    1.某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行,而多线程执行可以有效利用等待的时间。(等待网络响应)
    2.科学计算会消耗大量的时间,如果是多线程的话,一个线程负责交互,一个线程负责计算。
    3.程序逻辑本身需要并发操作,例如多端下载软件(Bittorrent)。
    4.单线程程序无法全面地发挥计算机的全部计算能力。
    5.相对于多进程应用,多线程在数据共享方面效率要高很多。
    
线程的优点
    创建一个新线程的代价比创建新进程小得多
    占用资源少
    线程之间的切换需要OS做的少很多
    能够利用多处理器的可并行数量
    在等待慢速I/O操作结束的同时,程序可执行其他的计算任务(网络I/O,磁盘I/O)
    计算密集型应用(科学计算),为了能在多处理器系统上运行,将计算分解到多个线程去实现
    I/O密集型应用(读写网络I/O),为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作(减少平均等待时间)
    线程间的通信方便,因为共享了很多数据。
    
I/O密集型线程总是比CPU密集型线程更容易得到优先级的提升。
    
线程缺点
    性能损失                   启动线程过多时,CPU不够用,线程之间的切换次数就会变多,导致效率变低。
    缺乏访问控制               eg: exit退出进程,而这些线程是隶属于同一个进程的,这样就会直接导致线程退出,不可控。
    健壮性降低                 一个线程在操作全局变量或者静态变量,某一个线程也在操作,
                               这可能会导致不良影响,换句话说,线程之间是缺乏保护的。(volatile)
    编程难度提高               考虑的问题变多
    
线程调度
    不断地在处理器上切换不同的线程的行为就叫做线程调度。
    
优先级调度
    在优先级调度的环境下,线程的优先级改变一般有三种方式:
        1.用户指定优先级;
        2.根据进入等待状态的频繁程度提升或者降低优先级;
        3.长时间得不到执行被提升优先级。(防止饿死)
        
抢占
    线程在用尽时间片之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占。
    
线程创建
    是有一个入口函数的。
    头文件 #include <pthread.h>
    pthread_t 是一个获取线程ID的一个类型 /usr/include/pthread.h     /usr/include/bits/pthreadtypes.h  
    unsigned long int    
    库函数int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                          void *(*start_routine) (void *), void *arg);
        1.线程标识符 tid(赋值型参数);2.线程属性参数(线程的继承,线程的堆栈大小,线程的保护区,线程的分离与等待),一般写NULL,默认属性;
        3.线程启动后执行的函数,线程函数指针,由4的参数传入(函数指针是个地址);4.传给线程启动函数的参数,即就是传入3的一个参数。
    关于第二个参数设置属性:
        
    关于参数3的返回值类型void * : 通知别人执行结束(正常退出还是异常退出)
    返回值:成功返回0,失败返回错误码。  读取返回值比读取线程内的errno变量的开销更小。
    返回错误码是因为多线程中的函数运行的时候,产生错误可能是同时的,那么就很有可能执行某一个线程产生错误,而另一个线程产生的
错误会修改之前的线程的错误码,这样是不允许的。
    
如何看到两个执行流
    pthread_create()函数的第三个参数就是一个函数指针,那么一个执行流执行下面的内容(主线程),
一个执行流去执行函数指针指向的函数中的内容(),这就是一个多线程。
    编译链接时加选项-pthread或者-lpthread,需要注意的是在Ubuntu系统中加-pthread选项会报错。
报错
    gcc create.c -o create
    /tmp/ccyAx6Ef.o: In function `main':
    create.c:(.text+0x7a): undefined reference to `pthread_create'
    collect2: error: ld returned 1 exit status
    Makefile:4: recipe for target 'create' failed
    make: *** [create] Error 1
    实际上是没有链接相关的库,这里需要加上选项-lpthread。
    
ps -aL 可以显示轻量级进程(线程)   主线程id和进程id是一样的   
    打印出来的tid仅仅表示在当前进程中的线程,每个线程私有地址区域的地址。
ps -efL 查看线程信息(轻量级进程)
    物理内存是一个空间,而虚拟地址相当于是一根线,这根线上的某个点表示一个编号,两点之间的距离就表示指向的某个区域大小。
    页表可以理解为一个解引用操作,访问控制。
    主线程:进程一开始创建的线程(默认有一个执行流)    线程没有父子关系,是一种对等的关系。
            主线程在主线程栈,其他线程在别的栈区(有自己的范围)。
        
        主线程id=进程id=线程组id
    进程id(pid)实际上指的是线程组id(tgid)
    tgid是线程组id 等于 主线程id(pid)   (也就是ps 查看到的pid)   
    tid是用户层面可以看到的线程id(一个比较大的数字,用%lu打印)  用%p打印是一串地址,是个编号。
指向共享存储映射区中的私有地址信息,这个私有栈是在进程的虚拟存储空间内部的。
    用户层面看到的是tgid
    通过tid转换成私有的地址信息就可以找到对应的线程。
    而LWP表示的是全局的线程ID(轻量级进程id,是在内核态的)  tid是在用户层面表示的内核态中的轻量级进程id
        Light Weighted Process
    NLWP:线程组内线程的个数.
进程描述符结构体中的pid,表面上看起来是进程id,实际上是线程id。  pid_t pid;
进程描述符结构体中的tgid,该值对应的是用户层面的进程id。  pid_t tgid;

主线程的栈是在PCB的栈区,其他线程的栈是在共享存储映射区(存储线程的虚拟地址)。  区域仅仅只是代表了一个范围(start_code, end_code)

// 线程退出      (谁调用它它就退出哪个线程)
void pthread_exit(void *retval);
    参数为线程退出的返回值     retval不要指向一个局部变量。
        主线程pthread_exit(NULL),那么主线程退出,产生两个僵尸线程。
        其他线程return 或者 pthread_exit(NULL),那么只是该线程不执行了。
        主线程return,主线程退出,如果在main函数中,那么进程退出。
    
// 取消一个执行中的线程(需要知道其他线程的线程id)   异常退出
int pthread_cancel(pthread_t thread);   //   成功返回0,失败返回错误码。

//获取线程标识符   不会出错
pthread_t pthread_self(void);  //谁调用它返回谁的tid(用户层面的tid)  
// 返回值与线程创建函数的第一个参数的返回值一样,是栈上的地址。

#include <sys/syscall.h>
// 获取线程标识符,这个值是和ps -efL 看到的一样。
    pid_t gettid(void);
    syscall(2); // 2表示内核中的一个系统调用的编号
    syscall(SYS_gettid);

为什么需要线程等待?
    1.已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
    2.创建新的线程不会复用刚才退出线程的地址空间。

默认线程是等待状态,要不然没人收尸。
// 阻塞式等待  (获取退出状态)
// 成功返回0   失败返回错误码
// 功能:等待线程结束。
    调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得
到的终止状态是不同的。

int pthread_join(pthread_t thread, void **retval);
// eg:  void *ptr = NULL;
        pthread_join(tid, &ptr);

    1.如果thread线程通过return返回,retval指向的单元里存放的是thread线程函数的返回值;
    2.如果thread线程被pthread_cancel异常终止,retval指向的单元里存放的是-1 (PTHREAD_CANCELED);
    3.如果thread线程是自己调用pthread_exit终止,retval指向的单元里存放的是传给pthread_exit的参数。
        
// 分离线程  (可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离)   
// 设置分离属性,指定线程退出后自动释放资源  不等待(不关心退出状态)   分离与等待属性是冲突的,
// 二者只能存在一个。就是说分离后就不能再等待了。
// 返回值:成功返回0 失败返回错误码
int pthread_detach(pthread_t thread);  // 在创建线程时,第二个参数可以传入线程分离属性,但是太麻烦,使用库函数简化操作。

    int pthread_attr_init(pthread_attr_t *attr);  // 线程属性初始化
    int pthread_attr_destory(pthread_attr_t *attr);  // 线程属性销毁
    
    定义一个pthread_attr_t变量
    
    Thread attributes:
       Detach state        = PTHREAD_CREATE_JOINABLE   // 分离属性
       Scope               = PTHREAD_SCOPE_SYSTEM      // 系统范围内的抢夺CPU资源
       Inherit scheduler   = PTHREAD_INHERIT_SCHED     // 继承调度策略
       Scheduling policy   = SCHED_OTHER               // 分时调度
       Scheduling priority = 0                         // 优先级
       Guard size          = 4096 bytes                // 线程栈末尾的警戒缓冲区大小
       Stack address       = 0x40196000                // 线程栈地址
       Stack size          = 0x201000 bytes            // 线程栈大小(线程栈之间是有安全距离的,不是紧挨的,防止栈溢出影响下一个线程)

同步:协作的时序性(一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。)
互斥:具有排他性
临界资源:具有排他性(只能一个人用)的资源
临界区:进程中访问临界资源的一段需要互斥执行的代码,其作用范围仅限于本进程。
临界区的访问规则
    空闲则入 忙则等待 有限等待 让权等待(可选)
    
线程安全
    单条指令的操作是原子的,它的执行是不会被打断的。
可重入
    一个函数被重入,表示这个函数没有执行完成,由于外部因素或者内部调用,又一次进入该函数执行。
    一个函数要被重入,有两种方式:
        1.多个线程同时执行该函数;
        2.函数递归。
volatile
    1.阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回;
    2.阻止编译器调整操作volatile变量的指令顺序。

--操作并不是原子操作,而是对应三条汇编指令:
    load:将共享变量ticket从内存加载到寄存器中
    update: 更新寄存器里面的值,执行-1操作
    store:将新值,从寄存器写回共享变量ticket的内存地址
    
pthread_mutex_t *mutex // 互斥量

    互斥量要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁。而信号量可以在一个线程获取之后由另一个线程释放。

访问临界资源时使用互斥量。
1.初始化互斥量;2.互斥量加锁;3.临界资源的操作;4.解锁。
    互斥量的库:/usr/include/pthread.h
    
// 初始化锁(需要销毁)
pthread_mutex_init(&mutex, NULL);    // 第一个参数是互斥量,第二个是互斥量属性,  互斥量是个全局的   或者直接赋值(不需要销毁)
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;  // 静态分配互斥量,不需要销毁
注意:
    1.不要销毁一个已经加锁的互斥量;
    2.已经销毁的互斥量,要确保后面不会有线程再次尝试加锁。
    
//    成功返回0,失败返回错误码。
    pthread_mutex_trylock()   //非阻塞  尝试加锁,如果不能加锁立即报错,获取不到锁资源  如果可以加锁立即返回0
    pthread_mutex_timelock()  //有时间限制的锁(挂起等待有时间限制)
    pthread_mutex_lock(&mutex)      //加锁
    临界资源的操作(临界区,只允许一个线程执行,不允许多个线程同时执行)
    pthread_mutex_unlock(&mutex)    //解锁

// 销毁
pthread_mutex_destory(&mutex);
    
重点:加锁之后,要考虑到任意一个线程可能退出的状态,这时候就要去解锁,否则就有可能出现死锁。

信号量实际上是个计数器,表示可用资源的数目。
二元信号量实际上就是个互斥锁,它有两种状态:占用和非占用。它适合被单线程独占访问。

    普通线程退出时不显示僵尸状态信息。
    !ps 历史命令中最接近ps的一次命令。

产生死锁的四个必要条件:(全都满足)
    1.互斥条件;
    2.不可剥夺条件;(只能自己释放)
    3.请求与保持条件;(假设两个锁A和B,那么获取到A锁后,但是无法获取B锁,这时候并不会释放掉A锁,一直等待)
    4.环路等待条件。(双方互相请求对方的资源,但是不去释放自己的资源,重点是环形等待)   哲学家就餐

避免死锁:银行家算法(消除隐患:安全和不安全,相当于判断系统可分配资源数目和当前想要分配资源数目)

生产者-消费者问题(生活中的竞争问题)
    生产者  ->    缓冲区   ->    消费者
有界缓冲区的生产者-消费者问题描述
    一个或者多个生产者在生成数据后放在一个缓冲区中,一个消费者从缓冲区中取出数据处理,
    任何时刻只能有一个生产者或者消费者可以访问缓冲区。
    
    限制条件:
        任何时刻只能有一个线程可以操作缓冲区(互斥访问)
        缓冲区空时,消费者必须等待生产者(条件同步)
        缓冲区满时,生产者必须等待消费者(条件同步)
    两个角色:生产者和消费者
    三种关系:生<->生:互斥    生<->消:互斥+同步    消<->消:互斥
    一个场所:交易场所,也就是缓冲区。

    生产者生产出来商品后,发送信号通知,但是这时候可能通知其他生产者,也可能通知消费者,
其中如果通知生产者时,由于这个时候缓冲区中有商品,那么生产者就会陷入等待,消费者没有将商品取出去,因此一直卡在那。
综上,应该发送信号通知给消费者,这样一个放,一个取,并且都会发送信号通知,这样就实现了生产者<->消费者模型。
    
为什么等待的时候要加入互斥量?
    条件变量是个全局的,通过互斥量来约束它。wait函数本身对mutex进行了一次原子操作。  (原子操作:解锁和休眠)
为什么 pthread_cond_wait 需要互斥量?
    条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所
以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。
没有互斥锁就无法安全的获取和修改共享数据。

条件变量
    作为一种同步手段,作用类似于一个栅栏。线程可以有两种操作:等待和唤醒。
    
// 初始化
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:互斥量
    进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。
eg: 条件等待
    pthread_mutex_lock(&mutex);
    while (条件为假)  // 因为pthread_cond_wait有可能被信号打断而唤醒
    pthread_cond_wait(&cond, &mutex);
    修改条件
    pthread_mutex_unlock(&mutex);
eg: 给条件发送信号
    pthread_mutex_lock(&mutex);
    设置条件为真
    pthread_cond_signal(&cond);   // 如果没有线程等待,信号被丢弃。
    pthread_mutex_unlock(&mutex);
    
// 唤醒单个等待
int pthread_cond_signal(pthread_cond_t *cond);

// 抢占式唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond);

信号量是一个包含等待队列的计数器。
    POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突地访问共享资源的目的,但POSIX信号量用于线程间同步,其创建
的信号量只有1个,而SystemV信号量是创建一个信号量集。
    POSIX信号量还创建了一块共享内存。
    链接时加上-lrt 或者-lpthread
#include <semaphore.h>
// 初始化信号量
int sem_init(sem_t *sem, int pshared, unsigned int value);
    参数:
        sem信号量
        pshared为0表示线程间共享,非0表示进程间共享。
        value是信号量初始值
    返回值:成功返回0,失败返回-1
// 销毁信号量
int sem_destroy(sem_t *sem);
// 功能:等待信号量,会将信号量的值减1,相当于P操作。
int sem_wait(sem_t *sem);
// 功能:发布信号量,表示资源使用完毕,可以归还资源了,将信号量值加1,相当于V操作。
int sem_post(sem_t *sem);

读写锁
    读写锁有两种获取方式:共享的或者独占的。
    应用场景:大量读,少量写
    读读共享,都写互斥,写优先级高。
    注意:写独占(其他写和读都是互斥),读共享,写锁优先级高。
    在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,
它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码
段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,
那就是读写锁。读写锁本质上是一种自旋锁。
    自旋锁:不停地询问是否有锁。   (短时间处理资源)
    读者写者
    指的是我们对共享数据的访问,一类是读者,只是读取数据不会去修改,一类是写者,可能会读和修改,
读的时候,可以通过线程同时读,但是写的时候不能同时写。也就说在同一时刻允许多个读者同时进行读操作,
第二条是说读写互斥,如果有写者正在写,那这时候你读者去读是没有意义的。第三个呢 是写写互斥,同时往里写数据,
这也是不允许的。

// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
// 读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 写锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
// 取消读写锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
// 销毁读写锁
int pthread_rwlock_destory(pthread_rwlock_t *rwlock);

猜你喜欢

转载自blog.csdn.net/sustzc/article/details/82734479