[linux复习]——多线程

发布于:2025-04-07 ⋅ 阅读:(28) ⋅ 点赞:(0)

1.linux线程概念

1.1什么是线程

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

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

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

        在linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化

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

1.2 线程的优点

        创建一个新线程的代价要比创建一个新进程小很多

        与进程之间的切换相比,线程之间的切换需要OS做的工作要少很多

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

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

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

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

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

1.3 线程的缺点

        性能缺失:一个很少被外部事件阻塞的计算密集线程往往无法与其他线程共享一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是额外的同步和调度开销,而可用的资源不变。

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

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

        编程难度提高:编写与调试一个多线程程序比单线程程序困难很多

1.4 线程异常

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

1.5 线程用途

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

1.6 linux线程vs进程

        进程是资源分配的基本单位
        线程是调度的基本单位
        线程共享进程数据,但也拥有自己的一部分数据:线程ID、一组寄存器、栈、errno、信号屏蔽字、调度优先级
        进程的多个线程共享 同一地址空间, 因此 Text Segment Data Segment 都是共享的 , 如果定义一个函数 , 在各线程中都可以调用, 如果定义一个全局变量 , 在各线程中都可以访问到 , 除此之外 , 各线程还共享以下进程资源和环境 :文件描述符、每种信号的处理方式、当前工作目录、用户id和组id

2 linux线程控制

2.1 POSIX线程库:

        与线程相关的函数构成了一个完成的系列,绝大多数函数的名字都是以“pthread_”打头的

        要使用这些函数库,要通过引入头文件<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 ;失败返回错误码
错误检查:
        传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以提示错误
        pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回
        pthreads同样提供了线程内的errno变量,以支持其他使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <pthread.h>

void *run(void *arg)
{
    int i;
    for(;;)
    {
        printf("I am thread 1\n");
        sleep(1);
    }
}


int main()
{
    pthread_t tid;
    int ret;
    if((ret = pthread_create(&tid,nullptr,run,nullptr)) != 0)
    {
        fprintf(stderr,"pthread_create : %s \n", strerror(ret));
        exit(EXIT_FAILURE);
    }
    int i;
    for(;;)
    {
        printf("I am main thread 1\n");
        sleep(1);
    }
    return 0;
}

        这份代码一但创建线程失败,主线程将读取返回的错误码

线程ID及进程地址空间布局
        pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事
        前面说的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值唯一表示该线程。
        pthread_create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据这个线程ID来操作线程的
        线程库NPTL提供了pthread_self函数,可以获取线程自身的ID;
pthread_t pthread_self(void);

        pthread_t对于现在的linux实现的NPTL而言,其本质上就是一个进程地址空间上的一个地址

线程终止

        如果需要某个线程而不是整个进程,可以有三种方法:

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

        2.线程可以调用pthread_exit终止自己

        3.一个线程可以调用pthread_cance终止同一个进程的另一个线程

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

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

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

线程等待

        已经退出的线程,其空间没有释放,仍然在进程的地址空间内。

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

功能:等待线程结束
原型
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 参数。

         下面通过代码演示这三种退出方式

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

分离线程:

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

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

int pthread_detach(pthread_t thread);

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

pthread_detach(pthread_self());
        joinable和分离是冲突的,一个线程不能既是 joinable 又是分离的。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
void *thread_run(void *arg)
{
    pthread_detach(pthread_self());
    printf("%s\n", (char *)arg);
    return NULL;
}
int main(void)
{
    pthread_t tid;
    const char* tmp = "thread1 run...";
    if (pthread_create(&tid, NULL, thread_run, (void *)tmp) != 0)
    {
        printf("create thread error\n");
        return 1;
    }
    int ret = 0;
    sleep(1); // 很重要,要让线程先分离,再等待
    if (pthread_join(tid, NULL) == 0)
    {
        printf("pthread wait success\n");
        ret = 0;
    }
    else
    {
        printf("pthread wait failed\n");
        ret = 1;
    }
    return ret;
}

2.2 linux线程互斥

进程线程间的互斥相关背景概念

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

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

        互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用

        原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

互斥量 mutex
        大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
        但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
        多个线程并发的操作共享变量,会带来一些问题,比如下面这段代码。
int ticket =100;

void *run(void *arg)
{
    char *id = (char*) arg;
    while(1){
        if(ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n",id,ticket);
            ticket--;
        }
        else break;
    }
}

int main()
{
    pthread_t id1,id2,id3,id4;
    char *ip1 = "thread 1";
    char *ip2 = "thread 2";
    char* ip3 = "thread 3";
    char* ip4 = "thread 4";

    pthread_create(&id1,nullptr,run,ip1);
    pthread_create(&id2,nullptr,run,ip2);
    pthread_create(&id3,nullptr,run,ip3);
    pthread_create(&id4,nullptr,run,ip4);

    pthread_join(id1,nullptr);
    pthread_join(id2,nullptr);
    pthread_join(id3,nullptr);
    pthread_join(id4,nullptr);
    return 0;
}

        上面这段代码之所以出现负数是因为:

        1.if语句判断条件为真以后,代码可以并发的切换到其他线程

        2.usleep这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段

        3.--ticket操作本身就不是一个原子操作(我前面的文章讲过原子库感兴趣的可以看看)

        对ticket的操作并不是原子的,因为它对应着三条汇编指令

        1.load:将共享变量ticket从内存加载到寄存器中

        2.update:更新寄存器里面的值,执行-1操作

        3.store:将新值,从寄存器写回共享变量ticket的内存地址

        要解决上面的问题,需要做到三点:

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

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

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

        

        要做到这三点,本质上就是需要一把锁。linux上提供的这把锁叫互斥量。

互斥量的接口

初识互斥量(有两种方法)

        法一:静态分配

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

销毁互斥量:

        销毁互斥量需要注意的事件:

        1.使用静态分配的互斥量不需要销毁

        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_mutex_lock时,可能会遇到以下情况:

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

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

//改进后的代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 100;
pthread_mutex_t mutex;
void *route(void *arg)
{
    char *id = (char *)arg;
    while (1)
    {
        pthread_mutex_lock(&mutex);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
            // sched_yield(); 放弃CPU
        }
        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }
}
int main(void)
{
    pthread_t t1, t2, t3, t4;
    pthread_mutex_init(&mutex, NULL);
    char *ip1 = "thread 1";
    char *ip2 = "thread 2";
    char *ip3 = "thread 3";
    char *ip4 = "thread 4";

    pthread_create(&t1, nullptr, route, ip1);
    pthread_create(&t2, nullptr, route, ip2);
    pthread_create(&t3, nullptr, route, ip3);
    pthread_create(&t4, nullptr, route, ip4);

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
    pthread_join(t4, nullptr);
    pthread_mutex_destroy(&mutex);
}

互斥量实现原理探究

      经过上面的例子,大家已经意识到单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题

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

  

2.3 可重入vs线程安全

概念

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

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

常见的线程不安全的情况

        不保护共享变量的函数

        函数状态随着被调用,状态发生变化的函数

        返回指向静态变量指针的函数

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

常见的线程安全的情况

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

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

        多个线程之间的切换不会导致该接口的执行结果存在二义性
常见不可重入的情况
        调用了malloc/free 函数,因为 malloc 函数是用全局链表来管理堆的
        调用了标准I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构
        可重入函数体内使用了静态的数据结构
常见可重入的情况
         不使用全局变量或静态变量
        不使用用malloc 或者 new 开辟出的空间
        不调用不可重入函数
        不返回静态或全局数据,所有数据都有函数的调用者提供
        使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全联系
        函数是可重入的,那就是线程安全的
        函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
        如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
可重入与线程安全区别
        可重入函数是线程安全函数的一种
        线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
        如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

3.常见锁概念

3.1 死锁

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

3.2 linux线程同步

条件变量:
        当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,它什么也做不了
        例如:一个线程访问队列时,发现队列为空,他只能等待,直到其他的线程将一个节点添加到队列中。这种情况就需要用到条件变量
同步概念与竞争条件:
        同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
        竞争条件:因为时序问题,而导致程序异常,我们称为竞争条件。在线程场景下,这种问题不难理解
条件变量函数初始化
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);

        代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond;
pthread_mutex_t mutex;
void *r1(void *arg)
{
    while (1)
    {
        pthread_cond_wait(&cond, &mutex);
        printf("活动\n");
    }
}
void *r2(void *arg)
{
    while (1)
    {
        pthread_cond_signal(&cond);
        sleep(1);
    }
}
int main(void)
{
    pthread_t t1, t2;
    pthread_cond_init(&cond, NULL);
    pthread_mutex_init(&mutex, NULL);
    pthread_create(&t1, NULL, r1, NULL);
    pthread_create(&t2, NULL, r2, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
}

为什么pthread_cond_wait需要互斥量

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

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

        

按照上面的说法,我们设计出如下的代码:先上锁,发现条件不满足,解锁,然后等待在条件变量上不就
行了,如下代码

// 错误的设计
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满足,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
        由于解锁和等待不是原子操作。调用解锁之后, pthread_cond_wait 之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么 pthread_cond_wait 将错过这个信号,可能会导致线程永远阻塞在这个 pthread_cond_wait 。所以解锁和等待必须是一个原子操作。
        int 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);


网站公告

今日签到

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