Linux:多线程

发布于:2024-08-23 ⋅ 阅读:(136) ⋅ 点赞:(0)

1:什么是线程

         A:在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”

        B:一切进程至少都有一个执行线程。

        C:线程在进程内部运行,本质是在进程地址空间内运行。

        D在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化,也叫轻量化进程。

        E透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

2:线程的优点 

        1:创建一个新线程的代价要比创建一个新进程小得多.

        2:与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。

        3:线程占用的资源要比进程少很多。

        4:能充分利用多处理器的可并行数量。

        5:在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。

        6:计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。

        7:I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

3:线程的缺点

 1:性能损失

        一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器(需要大量的CPU时间来执行,所以它们可能会占用处理器资源)。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。

2:健壮性降低

        编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。

3:缺乏访问控制

        进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。

4:编程难度提高

        编写与调试一个多线程程序比单线程程序困难得多。

4:线程异常

        1单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。

        2线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

5:线程用途

        1:合理的使用多线程,能提高CPU密集型程序的执行效率。

        2:合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。

6:进程 VS 进程

 A:进程是资源分配的基本单位线程是调度的基本单位

B:线程共享进程数据,但也拥有自己的一部分数据

      1  线程ID

      2: 一组寄存器栈

      3  errno

      4 信号屏蔽字调度优先级

C:多线程共享进程地址空间

        1:文件描述符表

        2:每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)当前工作目录

        3:用户id和组id

终于有人把进程与线程讲清楚了 - 知乎 (zhihu.com)icon-default.png?t=N7T8https://zhuanlan.zhihu.com/p/258049386

7:POSIX线程库

         <pthread.h>:链接这些线程函数库时要使用编译器命令的“-lpthread”选项。

功能:创建一个新的线程

原型
    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;失败返回错误码

错误检查:

        1:传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。

        2:pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。

        3:pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小。

8:线程ID及进程地址空间布局

        1:pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。

        2:前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。

        3:pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。

        4:线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID。

pthread_t pthread_self(void);
#include <pthread.h>
#include <stdio.h>

void *thread_function(void *args) {
    // 获取当前线程的标识符
    pthread_t this_thread = pthread_self();
    printf("Thread ID: %lu\n", (unsigned long)this_thread);
    // 线程的其他任务...
    return NULL;
}

int main() {
    pthread_t thread_id;
    if (pthread_create(&thread_id, NULL, thread_function, NULL) != 0) {
        perror("Failed to create thread");
        return 1;
    }

    // 也可以在主线程中获取自己的线程标识符
    pthread_t main_thread = pthread_self();
    printf("Main Thread ID: %lu\n", (unsigned long)main_thread);

    // 等待线程结束
    pthread_join(thread_id, NULL);
    return 0;
}

         pthread_t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,线程ID本质就是一个进程地址空间上的一个地址

9:线程终止 

         1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。

         2. 线程可以调用pthread_ exit终止自己。

void pthread_exit(void *value_ptr);

参数说明:
    value_ptr:这是一个 void * 类型的指针,它允许线程返回一个值给其他线程。这个值可以是任何类型,但是通常是一个整数或者一个结构体的指针。如果不需要返回值,可以传递 NULL。

         需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

功能:取消一个执行中的线程
原型
    int pthread_cancel(pthread_t thread);
参数
    thread:线程ID
    返回值:成功返回0;失败返回错误码

10:线程等待

为什么需要线程等待?
        1:已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。

        2:创建新的线程不会复用刚才退出线程的地址空间

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

        调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

        1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。

        2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。

        3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。

        4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

#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 %lX, 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 %lX, 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 %lX, return code:PTHREAD_CANCELED\n", tid);
    else
        printf("thread return, thread id %lX, return code:NULL\n", tid);
}

11:线程分离

        1默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

        2如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。

int pthread_detach(pthread_t thread);

        可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

        joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

 12:线程互斥

 背景概念

        1:临界资源多线程执行流共享的资源就叫做临界资源

        2:临界区每个线程内部,访问临界资源的代码,就叫做临界区

        3:互斥任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

 互斥量mutex

         1大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。

        2但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

        3多个线程并发的操作共享变量,会带来一些问题。 

抢票的例子:       

BuyingTickets · XiangChao/Linux - 码云 - 开源中国 (gitee.com)icon-default.png?t=N7T8https://gitee.com/RuofengMao/linux/tree/master/BuyingTickets怎么正确保证线程访问临界区,操作临界资源的原子性?

         1:代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

        2:如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。

        3:如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

互斥量(锁)

互斥量的接口

初始化互斥量

方法1,静态分配:

   pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法2,动态分配:

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

参数:
        mutex:要初始化的互斥量attr:NULL

 销毁互斥量

        1使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。

        2不要销毁一个已经加锁的互斥量。

        3已经销毁的互斥量,要确保后面不会有线程再尝试加锁。

int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量加锁和解锁


int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

返回值:成功返回0,失败返回错误号

调用 pthread_ lock 时,可能会遇到以下情况:

        1互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。

        2发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

13:可重入 VS 线程安全

 概念

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

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

常见的线程不安全的情况

        A:不保护共享变量的函数。

        B:函数状态随着被调用,状态发生变化的函数返回指向静态变量指针的函数。

        C:调用线程不安全函数的函数

常见的线程安全的情况

        1:每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。

        2:类或者接口对于线程来说都是原子操作。

        3:多个线程之间的切换不会导致该接口的执行结果存在二义性。

常见的不可重入

        1:调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。 

        2:调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

        3:可重入函数体内使用了静态的数据结构。

 常见可重入的情况

        1:不使用全局变量或静态变量。

        2:不使用用malloc或者new开辟出的空间。

        3:不调用不可重入函数。

        4:不返回静态或全局数据,所有数据都有函数的调用者提供。

        5:使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

可重入与线程安全联系

        A:函数是可重入的,那就是线程安全的

        B:函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题

        C:如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全区别

        A:可重入函数是线程安全函数的一种

        B:线程安全不一定是可重入的,而可重入函数则一定是线程安全的。

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

14:死锁

概念 

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

产生死锁的四个必要条件

        1:互斥条件:一个资源每次只能被一个执行流使用

        2:请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放

        3:不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺

        4:循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

 避免死锁

        1破坏死锁的四个必要条件。

        2加锁顺序一致。

        3避免锁未释放的场景。

        4资源一次性分配。

避免死锁算法

1:死锁检测算法

2:银行家算法

 15:线程同步

1:条件变量

        1:当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

        2:例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

同步概念与竞态条件

        1:同步在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步

        2:竞态条件因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

初始化

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:互斥量,后面详细解释

 唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

为什么 pthread_cond_wait 需要互斥量?

        1条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。

        2条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

先看个错误示范:

// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
    pthread_mutex_unlock(&mutex);
    //解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
    pthread_cond_wait(&cond);
    pthread_mutex_lock(&mutex);
} 
pthread_mutex_unlock(&mutex);

        2由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。

        1int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

条件变量使用规范

 等待条件代码

pthread_mutex_lock(&mutex);
while (条件为假){
    pthread_cond_wait(cond, mutex);
}
修改条件
    pthread_mutex_unlock(&mutex);

给条件发送信号代码

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

应用 

基于阻塞队列的生产消费模型 

2: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);

 等待信号量

功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

发布信号量

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

应用 

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

16:生产消费模型

为何要使用生产者消费者模型

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

生产者消费者模型优点

1:解耦

2:支持并发

3:支持忙闲不均


 

321原则 

17:基于阻塞队列的生产消费模型

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

Blockqueu_cp · XiangChao/Linux - 码云 - 开源中国 (gitee.com)icon-default.png?t=N7T8https://gitee.com/RuofengMao/linux/tree/master/Blockqueu_cp

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

        a环形队列采用数组模拟,用模运算来模拟环状特性(逻辑环)。

        b环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态。

RingBlockqueue_cp · XiangChao/Linux - 码云 - 开源中国 (gitee.com)icon-default.png?t=N7T8https://gitee.com/RuofengMao/linux/tree/master/RingBlockqueue_cp

 19:线程池

一种线程使用模式

        线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

线程池的应用场景

         1. 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。

         2. 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。

         3. 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误.

线程池示例

        1. 创建固定数量线程池,循环从任务队列中获取任务对象。

         2. 获取到任务对象后,执行任务对象中的任务接口。

ThreadPool · XiangChao/Linux - 码云 - 开源中国 (gitee.com)icon-default.png?t=N7T8https://gitee.com/RuofengMao/linux/tree/master/ThreadPool

20:线程安全的单例模式

单例模式的特点

  1. 单例类只能有一个实例:确保整个程序生命周期内只有一个实例被创建。
  2. 提供一个全局访问点:提供一种方式来访问这个唯一的实例,通常是一个静态方法。
  3. 线程安全:在多线程环境中,单例的实现需要确保线程安全。

懒汉实现方式和饿汉实现方式

懒汉模式

        懒汉式单例的核心思想是“延时加载”,即类实例在第一次被使用时才创建。这种方式可以延迟对象的创建,从而有可能优化程序的启动速度,特别是在单例对象的创建成本较高时。但是,懒汉式需要处理多线程环境下的线程安全问题。 

template <typename T>
class Singleton {
    static T* inst;
public:
    static T* GetInstance() {
        if (inst == NULL) {
          inst = new T();
        } 
        return inst;
    }
};

优点

  • 按需加载,节省资源。

缺点

  • 需要处理线程安全问题。
  • 每次获取实例时都要进行同步,可能会影响性能。

饿汉模式 

         饿汉式单例的特点是类实例在类加载时立即创建,即实例的创建与类的加载是同步的。这种方式的优点是实现简单,是线程安全的,但在单例对象的创建成本较高时,可能会影响程序的启动速度。

template <typename T>
class Singleton {
    static T data;
public:
    static T* GetInstance() {
        return &data;
    }
};

优点

  • 实现简单,是线程安全的。
  • 避免了同步问题,性能较高。

缺点

  • 不具备懒加载的特性,可能会影响启动速度。

 


网站公告

今日签到

点亮在社区的每一天
去签到