Linux——线程的创建与同步

一、线程的概念与实现方式

(一)线程的概念

线程是进程内部的一条执行序列或执行路径,是cpu调度的基本单元;一个进程可以包含多条线程

进程是系统中进行分配的基本单元

(二)线程的特性

  1. 线程间并发执行,具体调度根据操作系统不同决定
  2. 资源共享,线程共享进程的资源(全局变量,堆内存,文件描述符)
  3. 独立的栈空间,局部变量存储在各自的栈空间中

(三)线程的实现方式

在操作系统中,线程的实现有三种方式:

  • 内核级线程
  • 用户及线程
  • 组合级线程

Linux 中线程的实现:

Linux 实现线程的机制非常独特。从内核的角度来说,它并没有线程这个概念,Linux 把所有的线程都当作进程来实现。内核并没有准备特别的调度算法或是定义特别的数据结构来表示线程;相反,线程仅仅被视为一个与其他进程共享某些资源的进程,每个线程都拥有唯一隶属于自己的 task_struct,所以在内核中,它看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,如地址空间)。

linux 下的线程的本质就是通过 pcb-task_struct 结构体实现的,而这个pcb相较于传统操作系统设计的pcb更加轻量化,所以 linux 下的线程也被称作轻量级进程

(四)进程与线程的区别

进程详见:

|------------------|--------------------|
| 进程 | 线程 |
| 进程是系统进程资源分配的基本单元 | 线程是系统中进程CPU调度的基本单元 |
| 进程有自己的独立地址空间 | 线程共享进程中的地址空间 |
| 进程的创建消耗资源大 | 线程创建和销毁成本低 |
| 进程的切换开销大 | 同一进程内线程调度成本更低 |
| 进程的稳定性更高 | 线程间通信更加方便;执行力度更加细致 |

二、线程使用

(一)线程库中的接口介绍

1.创建线程

cpp 复制代码
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void *), void *arg);

/*
thread: 接收创建的线程的ID
attr: 指定线程的属性(默认NULL)
start_routinue: 指定线程函数
arg: 给线程函数传递的参数
返回值: 成功返回0,失败返回错误码
*/

2.退出线程

终止当前线程,并返回结果

cpp 复制代码
int pthread_exit(void *retval);

/*
retval: 指定退出信息
    如果不需要返回值,填NULL
    如果需要返回值,则必须指向全局变量或动态分配的内存(不能是栈区内存,因为线程终止后栈区内存会被释放)
*/

3.等待线程

等待某个线程退出并回收线程资源;线程未退出时,该方法阻塞

cpp 复制代码
int pthread_join(pthread_t thread, void **retval);

/*
thread: 等待的线程ID
retval: 接收线程返回值的指针
*/

4.线程分离

设置线程的分离属性:从 joinable 设置为 detach 状态

线程一旦被分离,线程退出后,就会自动释放所有资源,不需要被等待,也不能被等待

应用场景:当我们不关心一个线程的返回值,而且也并不希望阻塞在等待上,则使用线程分离

cpp 复制代码
int pthread_detach(pthread_t thread);

/*
thread: 等待的线程ID
*/

(二)多线程代码

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <pthread.h>

// 子线程入口函数
void *pthread_fun(void *arg)
{
    int i = 0;

    // 循环5次,模拟子线程任务
    for (; i < 5; ++i)
    {
        sleep(1); // 休眠1秒
        printf("fun thread running\n");
    }

    // 子线程退出,返回字符串常量的地址作为返回值
    pthread_exit("fun over");
}

int main()
{
    pthread_t tid; // 定义线程ID变量,用于存放创建出的子线程id

    // 创建子线程
    int res = pthread_create(&tid, NULL, pthread_fun, NULL);
    assert(res == 0); // 断言,创建失败程序直接终止

    // 主线程循环5次,模拟主线程任务
    int i = 0;
    for (; i < 5; ++i)
    {
        sleep(1);
        printf("main thread running\n");
    }

    char *s = NULL;

    // 阻塞等待子线程tid执行完毕,获取子线程的返回值存入s
    pthread_join(tid, (void **)&s);
    printf("s = %s\n", s); // 打印子线程通过pthread_exit返回的内容

    exit(0);
}

(三)线程并发运行

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>

// 线程入口函数
void *thread_fun(void *arg)
{
    // 把void*类型的参数强转为int*,解引用拿到index
    int index = *(int *)arg;
    int i = 0;

    // 循环5次打印当前线程的index
    for (; i < 5; i++)
    {
        printf("index=%d\n", index);
        sleep(1);
    }
}

int main()
{
    pthread_t id[5]; // 定义线程id数组,存放5个线程的ID

    // 循环创建5个子线程
    int i = 0;
    for (; i < 5; i++)
    {
        // 重点:这里传入&i,所有线程拿到的是同一个变量i的地址
        // 主线程循环不断修改i,线程读取时i的值已经发生变化,会出现结果错乱
        pthread_create(&id[i], NULL, thread_fun, (void *)&i);
    }

    // 循环等待5个子线程全部执行完毕
    for (i = 0; i < 5; i++)
    {
        pthread_join(id[i], NULL);
    }
    exit(0);
}

三、线程安全

(一)相关概念

**线程安全:**是指一个函数、类或数据结构在多线程环境中能够正确、一致地共工作,不会因为多个线程的并发访问而导致数据损坏、程序错误或异常行为

**临界资源:**是指那些能够被多个线程同时访问的共享资源

**临界区:**是指访问共享资源的那段代码,在这段代码中可能会发生竞态条件

**竞态条件:**当多个线程/进程并发访问共享资源,且最终结果依赖线程执行的相对时序时,就发生了竞态条件

**原子性:**一次性完成,中间不会被打断的操作,这种操作具备原子性


**函数的重入:**多个线程在并发执行中,同时进入同一个函数内部进行数据处理;也就是说,多线程同时调用同一个函数)

**函数的可重入:**多线程重入函数后,函数的执行结果并不会出现二义性(不会出现问题),这种函数叫做可重入函数。

**函数的不可重入:**多线程重入函数后,函数的执行结果并不确定,这种函数叫做不可重入函数。

**函数可重入与不可重入的本质原因:**函数内部是否依赖于临界资源

可重入函数是指执行过程中可以被中断(例如:信号),稍后能安全地重新进入的函数

可重入函数必然是线程安全的,但线程安全的函数不一定时可重入的(关键点在于是否可在中断后重入,线程安全可能是加锁实现的,但是一旦被信号中断就有可能导致死锁)


要保证线程安全需要做到:

(1)对线程同步,保证同一时刻只有一个线程访问临界资源

(2)在多线程中使用线程安全的函数(可重入函数)

(二)线程同步

线程同步指的是当一个线程在对某个临界资源进行操作时,其他线程都不可以对这个资源进行操作,直到该线程完成操作,其他线程才能操作,也就是协同步调,让线程按预定的先后次序进行运行。线程同步的方法有四种:互斥锁、信号量、条件变量、读写锁。

1.互斥锁

(1)本质

只有0和1的计数器+阻塞队列

(2)作用

用于保证临界资源的访问过程是安全的

通过0/1这个计数器,对当前锁的状态进行标记(已加锁/未加锁):访问资源之前进行加锁(未加锁状态直接加锁,已加锁状态则阻塞),访问资源完毕后进行解锁

(3)使用
  • 定义互斥锁
cpp 复制代码
// 定义锁变量
pthread_mutex_t mutex; 
  • 初始化
cpp 复制代码
// 方法一:定义变量时直接初始化
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
cpp 复制代码
// 方法二:通过函数进行初始化
pthread_mutex_init(pthread_ mutex_t *mutex, pthread_mutexattr_t *attr)

/*
mutex: 锁变量指针
attr: 属性(通常置空)
*/
  • 加锁

阻塞加锁,直到加锁成功后返回

cpp 复制代码
int pthread_mutex_lock(pthread_ mutex_t *mutex); // 阻塞加锁
/*
该加锁的阻塞有可能会被信号打断:
    如果在等待过程中遇到信号中断,则会先去处理中断,
    处理完毕后返回函数,不会自动继续等锁,而是直接返回错误码,
    但此时并未加锁成功
*/

int pthread_mutex_trylock(pthread_ mutex_t *mutex); // 非阻塞加锁

阻塞: 调用一个函数,内部要完成一个功能,但是当下无法立即完成,则一直等待,直到功能完成后函数返回

非阻塞: 调用一个函数,内部要完成一个功能,但是当下无法立即完成,则函数立即报错返回

  • 解锁
cpp 复制代码
int pthread_mutex_unlock(pthread_ mutex_t *mutex);
  • 释放锁
cpp 复制代码
int pthread_mutex_destroy(pthread_ mutex_t *mutex);

2.信号量

(1)本质

一个计数器+阻塞队列

信号量 ≠ 信号

信号:是系统中的软件中断

信号的使用详见:

Linux------信号的使用-CSDN博客https://blog.csdn.net/wy_05136/article/details/160348069?spm=1001.2014.3001.5501

(2)作用

实现同步与互斥,通过计数器对资源进行计数,用于同步控制线程当前是否可以获取资源


  • 信号量实现同步:计数器表示的是资源的数量

根据当前资源数量初始化信号量计数器初值,线程A获取资源之前进行P操作,线程B生产资源后进行V操作

  • 信号量实现互斥:计数器表示的是资源的状态

让信号的计数器永远保证只在0/1之间切换,将信号量初值设置为1,线程访问资源之前加锁(进行P操作);访问资源完毕后解锁(进行V操作)

(3)操作

P操作:获取资源前,对计数器 -1,若大于等于0则可以访问资源,否则就阻塞

V操作:生产资源后,对计数器+1,并唤醒至少一个阻塞的线程

(4)使用
  • 初始化
cpp 复制代码
int sem_init(sem_t *sem, int pshared, unsigned int value);

/*
sem:表示指向sem_t类型的指针
pshared:决定信号量的共享范围
	0:表示仅在当前进程内的所有线程共享
	非0值:在多个进程内共享(需要将该信号量放在共享内存中)
value:信号量计数器的初值
    用于实现互斥:初始化为1
    用于实现同步:初始化为实际的资源数量
*/
  • 获取资源(P操作)

原子操作,将信号量值减1,获取资源;如果信号量值为0,该操作会堵塞,此时将线程放入等待队列

cpp 复制代码
int sem_wait(sem_t *sem); // 计数器-1,若小于0则阻塞

int sem_trywait(sem_t *sem); // 非阻塞操作,计数器大于0则减1,正确返回;否则没有资源,报错返回

int sem_timedwait(sem_t *sem, const struct timespec *abc_timeout); //限时阻塞
  • 释放资源(V操作)

原子操作,将信号量值加1,释放资源,随机从等待队列中唤醒一条线程

cpp 复制代码
int sem_post(sem_t *sem);
  • 销毁

销毁初始化的信号量,释放所有与信号量相关的资源

cpp 复制代码
int sem_destroy(sem_t *sem);

3.条件变量

(1)本质

阻塞队列 + 让进程/线程阻塞的接口、唤醒进程/线程的接口

(2)作用

让线程在获取资源的时候,若不满足获取条件,则调用接口,让线程阻塞;其他线程促使资源获取的条件满足后,通过调用唤醒接口,唤醒所有阻塞的线程

(3)使用
  • 初始化
cpp 复制代码
int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *restrict attr);
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
  • 阻塞线程

在不满足资源获取的情况下阻塞线程

cpp 复制代码
// 阻塞接口
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

// 限时阻塞接口,超时后阻塞也会被唤醒
int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *restrict abstime);
  • 唤醒线程

促使资源获取条件满足后,唤醒阻塞的线程

cpp 复制代码
pthread_cond_signal(pthread_cond_t *cond); // 至少唤醒一个阻塞的线程

pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒所有阻塞的线程
  • 销毁
cpp 复制代码
pthread_cond_destroy(pthread_cond_t *cond);

注意事项:

(1)条件变量是搭配互斥锁一起使用的(条件判断的过程是一个临界资源访问的过程,需要加锁保护)

条件变量的阻塞,保证在获取资源不满足条件的时候,先解锁,然后再休眠

(2)条件判断的过程是一个循环判断的过程

多个线程被唤醒,只有一个加锁成功,其他的就会卡在加锁上

因此需要在被唤醒后,加锁成功后,重新再次判断是否满足条件,满足则处理,不满足则重新陷入休眠

(3)若同步的过程中,有多种不同的角色需要陷入休眠进行阻塞,需要创建多个条件变量,让不同的角色等待在不同的条件变量上,避免唤醒角色错误


信号量与条件变量在实现同步时的差别:

  1. 信号量在使用的时候不需要搭配互斥锁
  2. 信号量在实现同步的时候,是通过自身内部的计数器,对资源进行计数;计数判断过程不需要程序员参与,通过自身内部的计数器的值判断有没有资源可供获取(sem_wait 内部就包含了计数判断,不满足则阻塞)
  3. 条件变量在实现同步的时候,条件判断过程是程序员实现的;因此同步的时候,程序员进行条件判断,在合适的时候调用 pthread_cond_wait 进行阻塞

4.读写锁

(1)使用
  • 初始化
cpp 复制代码
int pthread_rwlock_init(pthread_rwlock_t *rwlock, pthread_rwlockattr_t *attr);
  • 加读锁
cpp 复制代码
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
  • 加写锁
cpp 复制代码
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
  • 解锁
cpp 复制代码
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
  • 销毁
cpp 复制代码
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
(2)读者写者模型
  • 应用场景:读共享、写互斥的场景

读共享:大家可以一起读

写互斥:当前写的时候,既不能有人读,也不能有人写

  • 实现:使用读写锁,主要在于满足读共享、写互斥需求

实现原理:读写锁内部有两个计数器:读者计数和写者计数

加读锁:写者计数为0就能加

加写锁:保证两个计数都为0才能加

(三)生产者与消费者模型

1.关键要素

生产者:产生数据的线程

消费者:处理数据的线程

线程安全的任务队列:缓冲区

  • 作用:(1)解耦合 (2)支持并发 (3)削峰填骨

削峰填骨:在高速生产数据的时候,数据来不及处理就先在队列中缓存起来,在数据量少的时候再慢慢处理


生产者与生产者之间的关系:互斥

生产者与消费者之间的关系:互斥+同步

消费者与消费者之间的关系:互斥

2.应用场景

程序中既要生产数据,又要对数据进行处理的场景

3.原理

将数据的生产与处理进行解耦(实现一个线程安全的任务队列);

生产者生产数据后,将数据抛入任务队列中;

消费者不断从任务队列中取出数据进行处理

4.优点

解耦合,支持并发,支持忙闲不均的削峰填骨

5.实现

cpp 复制代码
/*
使用条件变量+互斥锁,模拟厨师与顾客的同步关系:
    厨师: 有饭则阻塞,没饭则做饭
    顾客: 没饭则阻塞,有饭则吃饭
*/
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

int bowl = 0; // 0-没饭; 1-有饭
pthread_mutex_t mutex;
pthread_cond_t cond_cus; // 顾客等待的大厅
pthread_cond_t cond_che; // 厨师等待的后厨

void *customer(void *arg)
{
    while (1)
    {
        pthread_mutex_lock(&mutex);
        while (bowl == 0)
        {
            pthread_cond_wait(&cond_cus, &mutex);
        }
        // 能走下来代表当前bowl是1
        printf("我吃了一碗饭+++\n");
        bowl = 0;
        pthread_mutex_unlock(&mutex); // 先解锁还是先唤醒都可以
        pthread_cond_signal(&cond_che);
    }
    return NULL;
}

void *chef(void *arg)
{
    while (1)
    {
        pthread_mutex_lock(&mutex);
        while (bowl == 1)
        {
            pthread_cond_wait(&cond_che, &mutex);
        }
        printf("我做了一碗饭----\n");
        bowl = 1;
        pthread_mutex_unlock(&mutex);
        pthread_cond_signal(&cond_cus);
    }
    return NULL;
}

int main()
{
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond_che, NULL);
    pthread_cond_init(&cond_cus, NULL);
    
    pthread_t cus[4], chef_tid[4];
    int i, ret;

    // 创建4个顾客线程
    for (i = 0; i < 4; i++)
    {
        ret = pthread_create(&cus[i], NULL, customer, NULL);
        if (ret != 0)
        {
            printf("创建顾客线程失败!\n");
            return -1;
        }
    }
    // 创建4个厨师线程
    for (i = 0; i < 4; i++)
    {
        ret = pthread_create(&chef_tid[i], NULL, chef, NULL);
        if (ret != 0)
        {
            printf("创建厨师线程失败!\n");
            return -1;
        }
    }

    // 等待所有顾客线程
    for (i = 0; i < 4; i++)
    {
        pthread_join(cus[i], NULL);
    }
    // 等待所有厨师线程
    for (i = 0; i < 4; i++)
    {
        pthread_join(chef_tid[i], NULL);
    }

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond_che);
    pthread_cond_destroy(&cond_cus);
    return 0;
}

(四)死锁

1.概念

多个线程之间因为锁的争抢不当,导致线程间相互等待,若无外力介入无法继续执行的线程

2.产生

死锁的产生有四个必要条件

  1. 互斥条件:锁只有一个线程能加
  2. 不可剥夺条件:线程A加的锁,其他线程不能解
  3. 持有并等待条件:线程A加了a锁,然后去请求b锁,加不上b锁,我也不释放a锁
  4. 环路等待条件:线程A加了a锁,请求b锁;线程B加了b锁请求a锁

3.预防

预防的关键就是破坏四个必要条件

  • 破坏持有并等待:可以使用非阻塞加锁trylock,若加锁不成功,则释放已经加的锁
  • 破坏环路等待:环路等待大多是因为加锁顺序不一致导致的,因此保证所有线程加锁顺序一致

4.避免

避免有两种方法:银行家算法、死锁检测算法

银行家算法思想:

  1. 使用一个矩阵描述总共有多少资源
  2. 使用一个矩阵描述给哪些客户分配了哪些资源
  3. 使用一个矩阵描述哪些客户还需要哪些资源
  4. 对系统运行使用两种状态进行描述:安全、不安全(若给一个客户分配了资源会导致系统进入不安全状态,则不能分配)

(四)锁的种类(了解)

1.乐观锁

总是认为在访问临界资源的时候,其他线程不会访问

2.悲观锁

总是认为在访问临界资源的时候有其他线程争抢,因此总是在访问前加锁

3.递归锁

常用于递归函数中,同一个线程内可以递归循环加锁

4.自旋锁

能加锁则加锁,不能加锁则循环判断能否加锁

适用场景:自旋锁对cpu资源消耗非常高,因此常用于对效率要求高,临界区操作比较短小,且时间较短可控的场景

相关推荐
Lsetea2 小时前
OpenSSL读取加密私钥报bad decrypt:passin与口令来源排查
linux·https·ssl证书·openssl·私钥
FY@-@2 小时前
Shell 脚本编程:条件判断与循环
linux·运维·前端
Peregrine92 小时前
Linux 基本指令大全
linux·运维·服务器
拂拉氏2 小时前
【知识讲解】 Linux动静态链接认识
linux·动静态链接
做运维的阿瑞3 小时前
利用GitHub配置镜像加速,解决 Docker镜像拉取问题
运维·docker·容器·github
Huangjin007_3 小时前
【Linux 系统篇(二十八)】文件(五):Ext 系列文件系统(下):Ext2块组结构,inode与文件寻址原理
linux·运维·服务器
javachen__3 小时前
JsonView:一套VO实现多角色字段返回
java·linux·服务器
睡一觉就好了。3 小时前
TcpSocket
linux
小陈运维3 小时前
使用kubeadm初始化IPV4/IPV6集群
linux·docker·云原生·容器·kubernetes