Linux学习_线程

1. 线程概念

1.在一个程序里的一个执行路线就叫做线程( thread) 。更准确的定义是:线程是 一个进程内部的控制序列”。
2.一切进程至少都有一个执行线程。
3.线程在进程内部运行,本质是在进程地址空间内运行。
4.在 Linux 系统中,在 CPU 眼中,看到的 PCB 都要比传统的进程更加轻量化
5.透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

2. 线程的操作

        首先,要明确Linux是没有真线程的,它只有轻量级进程,所以在内核里它只有轻量级进程的系统调用,没有线程的系统调用。用户所看到的线程接口实际上是对轻量级进程的系统调用的封装。这些封装在原生线程库:pthread库,这也是linux系统必须自带的库,要编写多线程的程序,就一定要链接pthread库(l -pthread)。

2.1 线程的创建

功能:创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
参数:
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
        其中的thread是返回线程ID,是输出型参数。可以用如下函数获得线程自身的ID:
pthread_t pthread_self(void);

2.2 线程的终止

功能:线程终止
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码
        如果需要只终止某个线程而不终止整个进程,可以有如下方法:
        1. 从线程函数 return 。这种方法对主线程不适用 , main 函数 return 相当于调用 exit
        2. 线程可以调用 pthread_ exit 终止自己
        3. 一个线程可以调用 pthread_ cancel 终止同一进程中的另一个线程
        注意: pthread_exit或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的, 不能在线程函数的栈上分配。

2.3 线程的等待

功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

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

        调用该函数的线程将挂起等待, 直到 id thread 的线程终止。 thread 线程以不同的方法终止,value_ptr所指向的单元里存放的值也 不同,总结如下:
        1. 如果 thread 线程通过 return 返回,值是 thread线程函数的返回值
        2. 如果 thread 线程被别的线程调用 pthread_cancel 异常终掉, PTHREAD_ CANCELED
        3. 如果 thread 线程是自己调用 pthread_exit 终止的,值 是传给 pthread_exit 的参数
        4. 如果对 thread 线程的终止状态不感兴趣 , 可以传 NULL value_ ptr 参数

2.4 线程的分离

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
int pthread_detach(pthread_t thread);
int pthread_detach(pthread_self());
        默认情况下,新创建的线程是joinable 的,线程退出后需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。
        如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

3. 线程与进程

进程和线程的关系如下图 :

        进程资源分配的基本单位,而 线程调度的基本单位。
        线程间共享:代码和全局数据、进程的文件描述表、信号的处理方式、当前目录、用户ID和组ID。
        线程私有:线程的硬件上下文(CPU寄存器的值)、独立的栈结构、线程ID、信号屏蔽字、调度优先级、errno。

4. 线程的优缺点

1. 优点:
        1.创建一个新线程的代价要比创建一个新进程小得多
        2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
        3.线程占用的资源要比进程少很多
        4.能充分利用多处理器的可并行数量
        5.在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
        6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
        7.I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作

2. 缺点:
        1.性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
        2.健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的

        3.缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响
        4.编程难度提高:因为健壮性低、缺乏访问控制

5. 线程的其他

5.1 线程的异常

        单个线程如果出现除零野指针问题导致线程崩溃,进程也会随着崩溃。   
        线程进程执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,该进程内的所有线程也就随即退出。

5.2 线程的用途

        合理的使用多线程,能提高CPU 密集型程序的执行效率。
        合理的使用多线程,能提高IO密集型程序的用户体验(如一边写代码一边下载开发工具)。

5.3 线程的切换(了解)

        与进程间的切换相比,线程间的切换需要操作系统做的工作要少很多。这是因为CPU在调度执行流的时候,如果每次都要重新从内存中读取数据到CPU是很浪费效率的,根据局部性原理,所以为了提高效率,从内存中读取的数据会保存到CPU中的Cache(高速缓冲存储器)。缓存的出现就可以极大提高线程切换的效率,与进程相比,线程所切换的资源很少。

5.3 pthread_t类型

        pthread_t到底是什么类型取决于实现。对于 Linux 而言, pthread_t 类型的线程 ID ,本质就是一个进程地址空间上的一个地址,是线程的Tcb起始地址。所有线程的管理工作是由pthread库统一管理的,所以线程库也要映射到当前进程地址空间中。

6. 线程的互斥

6.1 互斥量

        大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题,如数据不一致问题。这些并发访问时出现的问题,多个执行流执行访问全局数据的代码导致的,所以要保护全局共享资源,也就是保护临界区这时候就需要一把锁,也就是互斥量。加锁的本质就是把并发执行变成串行执行

        在讲互斥量前,要先知道下面的一些概念:
        1.临界资源:多线程执行流共享的资源就叫做临界资源
        2.临界区:每个线程内部,访问临界资源的代码,就叫做临界区
        3.互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
        4.原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

6.2 互斥量接口

1. 初始化:

静态分配:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

动态分配:
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
参数:
mutex:要初始化的互斥量
attr:NULL

2. 销毁:

int pthread_mutex_destroy(pthread_mutex_t *mutex);

        注意:
        1. 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
        2. 不要销毁一个已经加锁的互斥量
        3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

3. 加锁与解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,
那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

6.3 互斥原理

        为了实现互斥锁操作, 大多数体系结构都提供了 swap exchange 指令, 该指令的作用是把寄存器内存单元的数据相交换, 由于只有一条指令, 保证了原子性 即使是多处理器平台 访问内存的总线周期也有先后, 一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。现在可以把lock unlock的伪代码改成:

        上面说到的交换,本质不是拷贝到寄存器,而是所有线程在争抢锁(1)的时候,只有一个数据1。CPU的寄存器硬件只有一套,但是寄存器内的数据,即线程的硬件上下文数据。如果数据在内存里,所有线程都能访问,属于共享。但是一旦转移到CPU寄存器里,就属于这个线程私有的了。

7. 可重入VS线程安全

7.1 线程安全

        概念:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
线程不安全 线程安全
不保护共享变量的函数 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限
函数状态随着被调用,状态发生变化的函数 类或者接口对于线程来说都是原子操作
返回指向静态变量指针的函数 多个线程之间的切换不会导致该接口的执行结果存在二义性
调用线程不安全函数的函数

7.2 可重入

        概念:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
可重入
不可重入
调用了 malloc / free 函数,因为 malloc 函数是用全局链表来管理堆的
不使用全局变量静态变量
调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
不使用用 malloc 或者 new 开辟出的空间
可重入函数体内使用了静态的数据结构
不调用不可重入函数
不返回静态全局数据,所有数据都有函数的调用者提供
使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

7.3 可重入与线程安全的联系和区别

        a. 可重入与线程安全联系:
        1. 函数是可重入的,那就是线程安全的
        2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
        3. 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
        b .可重入与线程安全区别
        1. 可重入函数是线程安全函数的一种
        2. 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
        3.如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的

8. 线程的同步

        同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。同步的实现靠条件变量
        竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。

8.1 条件变量

        概念:当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
        例如一个线程访问队列时,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

8.2 条件变量接口

1.  初始化
全局静态:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
cond:要初始化的条件变量
attr:给NULL

注意:
1. 全局的条件变量自己会初始化和销毁,局部的才要手动。

2. 销毁

int pthread_cond_destroy(pthread_cond_t *cond)

 3. 等待

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:所持有的互斥量(锁)
        条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
        条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据

4. 唤醒

唤醒等待中的所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);

唤醒等待中的某个线程
int pthread_cond_signal(pthread_cond_t *cond);

9. 生产者消费者模型

9.1 概念        

        生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
        优点: 解耦、 支持并发、 支持忙闲不均

        仓库是某种数据结构,是数据“交易”的场所。
        该模型尽管有很好的并发度,但也存在着并发问题:
        1. 生产者与生产者间
        2. 生产者与消费者间
        3. 消费者与消费者间

9.2 基于BlockingQueue的生产者消费者模型

        在多线程编程中阻塞队列(Blocking Queue) 是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出( 以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。

核心代码:
 

    // 生产者调用的接口
    void Enqueue(T &in)
    {
        pthread_mutex_lock(&_mutex);
        // bq满了就不能继续生产,需要等待,直到不满再获取锁
        while (IsFull())
        {
            _producer_wait_num++;
            // pthread_cond_wait调用:a.让当前线程在临界区等待 b.释放获得的锁 c.条件满足,线程被唤醒,重新竞争锁
            // 上面的过程是线性的
            pthread_cond_wait(&_product_cond, &_mutex);
            _producer_wait_num--;
        }
        _blockqueue.push(in);
        // 如果当前有消费者在等待,就唤醒消费者
        // 不用每次都要唤醒,一旦没有消费者在等待,就是无效功
        if (_consumer_wait_num > 0)
            pthread_cond_signal(&_consum_cond);
        pthread_mutex_unlock(&_mutex);
    }
    // 消费者调用的接口,输出型参数
    void Pop(T *out)
    {
        pthread_mutex_lock(&_mutex);
        // 如果用if判断条件,会出现伪唤醒
        while (IsEmpty())
        {
            _consumer_wait_num++;
            pthread_cond_wait(&_consum_cond, &_mutex);
            _consumer_wait_num--;
        }
        *out = _blockqueue.front();
        _blockqueue.pop();
        if (_producer_wait_num > 0)
            pthread_cond_signal(&_product_cond);//唤醒可以在解锁前,也可以在后
        pthread_mutex_unlock(&_mutex);
    }

10. POSIX信号量

        POSIX信号量和 SystemV 信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但 POSIX 可以用于线程间同步。

10.1 信号量接口

1. 初始化信号量

#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值

2. 销毁信号量

int sem_destroy(sem_t *sem);

3. 等待信号量

功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); 
P()操作的底层实现

4. 发布信号量

功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);
V()操作的底层实现

10.2 基于环形队列的生产消费模型

        环形队列采用数组模拟,用模运算来模拟环状特性:

        环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态。但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程。

 核心代码:

void P(sem_t &sem)
{
    sem_wait(&sem);
}
void V(sem_t &sem)
{
    sem_post(&sem);
}
// 生产者关心空间
void Enqueue(const T &in)
{
    // 因为P、V操作是原子的,可以放在加锁|解锁外面
    // 理解为:先买票(P),再排队(竞争锁)
    //    因为竞争时,每个线程已经分配好信号量了
    // 效率比:先排队(竞争锁),再买票(P) 高
    //    因为竞争时,只有竞争锁成功的线程才能继续分配信号量,期间其它线程只能等待
    P(_room_sem);
    Lock(_producer_mutex);
    _ringqueue[_produceridx++] = in;
    _produceridx %= _capacity;
    Unlock(_producer_mutex);
    V(_data_sem);
}
// 消费者关心数据
void Pop(T *out)
{
    P(_data_sem);
    Lock(_consumer_mutex);
    *out = _ringqueue[_consumeridx++];
    _consumeridx %= _capacity;
    Unlock(_consumer_mutex);
    V(_room_sem);
}

11. 常见锁概念

11.1 死锁

        死锁:指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所站用不会释放的资源而处于的一种永久等待状态。
        死锁四个必要条件:
        1. 互斥条件:一个资源每次只能被一个执行流使用
        2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
        3. 不剥夺条件: 一个执行流已获得的资源,在末使用完之前,不能强行剥夺
        4. 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系
        
        为了避免死锁,可以通过破坏死锁的四个必要条件、加锁顺序一致、避免锁未释放的场景、资源一次性分配,或者采用银行家算法或死锁检测算法。

11.2 自旋锁

        自旋锁:获取锁失败时,线程不会进入阻塞状态,而会以自旋的方式反复尝试获取锁,直到获取成功或达到一定的尝试次数。
        自旋锁的核心思想是快速尝试获取锁,避免线程进入阻塞状态,从而减少线程切换的开销。
        不论是阻塞等待还是自旋,都是等待检测锁就绪的策略。

        自旋锁的使用和普通锁的使用在表现上是类似的,它的调用接口模式参考锁的接口,以下是自旋锁的接口:

初始化
int pthread_spin_init(pthread_spinlock_t *lock,int pshared);

销毁
int pthread_spin_destory(pthread_spinlock_t *lock);

加锁
int pthread_spin_lock(pthread_spinlock_t *lock);

解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);

        在临界区中的线程执行时间长短和自旋锁的关系:
        1. 如果时间长,推荐用普通锁,其他阻塞挂起等待
        2. 如果时间短,推荐用自旋锁,其他线程不阻塞等待,而是一直抢占锁,直到成功

12. STL,智能指针和线程安全

1. STL 中的容器是否是线程安全的 ?
        不是.。原因是,STL 的设计初衷是将性能挖掘到极致, 而一旦涉及到加锁保证线程安全, 会对性能造成巨大的影响。而且对于不同的容器, 加锁方式的不同, 性能可能也不同 ( 例如 hash 表的锁表和锁桶 )。
        因此 STL 默认不是线程安全。 如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
2. 智能指针是否是线程安全的 ?
        对于 unique_ptr 由于只是在当前代码块范围内生效, 因此不涉及线程安全问题。
        对于 shared_ptr 多个对象需要共用一个引用计数变量, 所以会存在线程安全问题。 但是标准库实现的时候考虑到了这个问题, 基于原子操作 (CAS) 的方式保证 shared_ptr 能够高效、 原子的操作引用计数。

相关推荐

  1. LINUX自启动线学习笔记

    2024-06-12 13:44:01       59 阅读
  2. linux 线笔记

    2024-06-12 13:44:01       52 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-06-12 13:44:01       94 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-06-12 13:44:01       100 阅读
  3. 在Django里面运行非项目文件

    2024-06-12 13:44:01       82 阅读
  4. Python语言-面向对象

    2024-06-12 13:44:01       91 阅读

热门阅读

  1. 微信小程序页面配置

    2024-06-12 13:44:01       28 阅读
  2. AWS无服务器 应用程序开发—第一章 目录

    2024-06-12 13:44:01       32 阅读
  3. MySQL密码复杂度策略配置

    2024-06-12 13:44:01       31 阅读
  4. 为什么选择Scala?大数据开发从入门到精通

    2024-06-12 13:44:01       32 阅读
  5. iPadOS 18支持的设备列表

    2024-06-12 13:44:01       28 阅读
  6. Python基础学习笔记(十)——初探正则

    2024-06-12 13:44:01       32 阅读
  7. QT 中文乱码 以及 tr 的使用

    2024-06-12 13:44:01       27 阅读
  8. 【docker实战】如何登陆到自己的私有仓库?

    2024-06-12 13:44:01       31 阅读