c语言tips-【C语言多线程编程】

news2024/11/16 16:22:41

0.摘要

操作系统具有管理进程进程调度的能力,线程,决定哪个进程、线程使用 CPU。很多时候我们需要在同一时间干不同的任务,这就需要我们通过多进程或者多线程来进行,在我们学习和工作中我们大部分用到的都是多线程,本文主要是在linux下探索c语言的多进程的使用方法

文中的内容大部分是从大丙老师博客地址(https://subingwen.cn/linux)那里copy来的,有些内容是为了完善内容体系或者我自己的理解加的

1. 进程和线程的基本概念

  • 进程:进程是系统进行资源分配和调度的一个独立单位,是系统中的并发执行的单位。
  • 线程:线程是进程的一个实体,也是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位,有时又被称为轻权进程或轻量级进程。

2. 进程与线程的区别?

  • 进程是资源分配的最小单位,而线程是 CPU 调度的最小单位;
  • 创建进程或撤销进程,系统都要为之分配或回收资源,操作系统开销远大于创建或撤销线程时的开销;

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行

  • 不同进程地址空间相互独立,同一进程内的线程共享同一地址空间。一个进程的线程在另一个进程内是不可见的;
  • 进程间不会相互影响,而一个线程挂掉将可能导致整个进程挂掉;
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了
  • 线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

3. 为什么有了进程还要线程?

进程可以使多个程序并发执行,以提高资源的利用率和系统的吞吐量,但是其带来了一些缺点:

  • 进程在同一时间只能干一件事情;
  • 进程在执行的过程中如果阻塞,整个进程就会被挂起,即使进程中有些工作不依赖与等待的资源,仍然不会执行。

基于以上的缺点,操作系统引入了比进程粒度更小的线程,作为并发执行的基本单位,从而减少程序在并发执行时所付出的时间和空间开销,提高并发性能。

3.多线程建立

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>


void* callback(void* arg)
{
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    printf("子线程ID:%ld\n", pthread_self());
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, callback, NULL);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
    return 0;
}
  • pthread.h:多线程库

  • pthread_t pthread_self(void);

    • 作用:返回线程的ID
    • 返回值:每一个线程都有一个唯一的线程 ID,ID 类型为 pthread_t,这个 ID 是一个无符号长整形数
  • int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

    • 作用:创建一个线程
    • args:
      • thread: 传出参数,是无符号长整形数,线程创建成功,会将线程 ID 写入到这个指针指向的内存中
      • attr: 线程的属性,一般情况下使用默认属性即可,写 NULL
      • start_routine: 函数指针,创建出的子线程的处理动作,也就是该函数在子线程中执行。
      • arg: 作为实参传递到 start_routine 指针指向的函数内部
      • 返回值:线程创建成功返回 0,创建失败返回对应的错误号
gcc pthread_create.c -lpthread # 要链接上静态库:线程库的名字叫pthread, 全名: libpthread.so libptread.a

值得注意的是执行上述代码后在打印的日志输出中为什么子线程处理函数没有执行完毕呢(只看到了子线程的部分日志输出)?
主线程一直在运行,执行期间创建出了子线程,说明主线程有 CPU 时间片,在这个时间片内将代码执行完毕了,主线程就退出了。子线程被创建出来之后需要抢cpu时间片, 抢不到就不能运行,如果主线程退出了, 虚拟地址空间就被释放了, 子线程就一并被销毁了。但是如果某一个子线程退出了, 主线程仍在运行, 虚拟地址空间依旧存在.

得到的结论:在没有人为干预的情况下,虚拟地址空间的生命周期和主线程是一样的,与子线程无关。

目前的解决方案:让子线程执行完毕,主线程再退出,可以在主线程中添加挂起函数 sleep();

  • 于是我们又有了下面这块代码,即加了一个sleep(1)
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>


void* callback(void* arg)
{
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    printf("子线程ID:%ld\n", pthread_self());
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, callback, NULL);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
    sleep(1);
    return 0;
}
  • 在编写多线程程序的时候,如果想要让线程退出,但是不会导致虚拟地址空间的释放(针对于主线程),我们就可以调用线程库中的线程退出函数,只要调用该函数当前线程就马上退出了,并且不会影响到其他线程的正常运行·,不管是在子线程或者主线程中都可以使用。这样也可以让子线程正常的执行
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>


void* callback(void* arg)
{
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    printf("子线程ID:%ld\n", pthread_self());
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, callback, NULL);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
	// 主线程调用退出函数退出, 地址空间不会被释放
    pthread_exit(NULL);
    return 0;
}

所用函数

  • void pthread_exit(void *retval);
    • 参数:线程退出的时候携带的数据,当前子线程的主线程会得到该数据。如果不需要使用,指定为 NULL

4.线程数据回收

线程和进程一样,子线程退出的时候其内核资源主要由主线程回收,线程库中提供的线程回收函叫做 pthread_join(),这个函数是一个阻塞函数,如果还有子线程在运行,调用该函数就会阻塞,子线程退出函数解除阻塞进行资源的回收,函数被调用一次,只能回收一个子线程,如果有多个子线程则需要循环进行回收。

另外通过线程回收函数还可以获取到子线程退出时传递出来的数据,函数原型如下:

  • int pthread_join(pthread_t thread, void **retval);
    • 参数
      • thread: 要被回收的子线程的线程 ID
      • retval: 二级指针,指向一级指针的地址,是一个传出参数,这个地址中存储了 pthread_exit () 传递出的数据,如果不需要这个参数,可以指定为 NULL
    • 返回值:返回值:线程回收成功返回 0,回收失败返回错误号。

在子线程退出的时候可以使用 pthread_exit() 的参数将数据传出,在回收这个子线程的时候可以通过 phread_join() 的第二个参数来接收子线程传递出的数据。接收数据有很多种处理方式,下面来列举几种:

4.1 使用子线程栈回收资源

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

struct Test
{
    /* data */
    int  num;
    int  age;
};

void* callback(void* arg)
{
    // 此时我们把结构体放在子线程的栈中
    struct Test  test;
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    test.num = 100;
    test.age = 18;
    printf("子线程ID:%ld\n", pthread_self());
    // 子线程结束后传出数据
    pthread_exit(&test);
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, callback, NULL);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
    // 主线程结束后不会释放地址空间,即主线程结束不会让子线程结束
    //pthread_exit(NULL);
    // 另外一种可以让子线程执行完毕的方法:让主线程堵塞,等待子线程执行完再结束
    void *ptr=NULL;
    // 这个ptr是一级指针,&ptr取出它的二级指针,返回的值是子线程pthread_exit(&test);这个语句传出的test
    pthread_join(tid,  &ptr);
    // 强制类型转换,将ptr转化为指向pt的指针
    struct Test* pt = (struct Test*)ptr;
    printf("年龄为:%d\n", pt->age);
    printf("数字为:%d\n",pt->num);
    return 0;
} 
主线程:i = 0
主线程:i = 1
主线程:i = 2
主线程:i = 3
主线程:i = 4
主线程ID:140555521054528
子线程:i=0
子线程:i=1
子线程:i=2
子线程:i=3
子线程:i=4
子线程ID:140555512719104
年龄为:32725
数字为:-1595400192

运行上面程序我们可以看到,打印的年龄和数字不是我们在子线程中定义的在主线程中没有没有得到子线程返回的数据信息,具体原因是这样的:

如果多个线程共用同一个虚拟地址空间,每个线程在栈区都有一块属于自己的内存,相当于栈区被这几个线程平分了,当线程退出,线程在栈区的内存也就被回收了,因此随着子线程的退出,写入到栈区的数据也就被释放了。即数据就会变得很奇怪

4.2 使用全局变量

位于同一虚拟地址空间中的线程,虽然不能共享栈区数据,但是可以共享全局数据区和堆区数据,因此在子线程退出的时候可以将传出数据存储到全局变量静态变量或者堆内存中。即我们可以将结构体的定义放在全局区。在下面的例子中将数据存储到了全局变量中:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

struct Test
{
    /* data */
    int  num;
    int  age;
};

// 我们将结构体放在全局变量即放在全局区,这样子线程被释放后也不会出现问题
struct Test  test;

void* callback(void* arg)
{
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    test.num = 100;
    test.age = 18;
    printf("子线程ID:%ld\n", pthread_self());
    // 子线程结束后传出数据
    pthread_exit(&test);
    return NULL;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, callback, NULL);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
    // 主线程结束后不会释放地址空间,即主线程结束不会让子线程结束
    //pthread_exit(NULL);
    // 另外一种可以让子线程执行完毕的方法:让主线程堵塞,等待子线程执行完再结束
    void *ptr=NULL;
    // 这个ptr是一级指针,&ptr取出它的二级指针,返回的值是子线程pthread_exit(&test);这个语句传出的test
    pthread_join(tid,  &ptr);
    struct Test* pt = (struct Test*)ptr;
    printf("%d\n", pt->age);
    printf("%d\n",pt->num);
    return 0;
}

将数据放在全局区,这样子线程退出后返回的数据在主线程就能拿到了

4.3 使用主线程栈

虽然每个线程都有属于自己的栈区空间,但是位于同一个地址空间的多个线程是可以相互访问对方的栈空间上的数据的。由于很多情况下还需要在主线程中回收子线程资源,所以主线程一般都是最后退出,基于这个原因在下面的程序中将子线程返回的数据保存到了主线程的栈区内存中

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

struct Test
{
    /* data */
    int  num;
    int  age;
};

void* callback(void* arg)
{
    struct Test* p = (struct Test*)arg;
    // 此时我们把结构体放在子线程的栈中
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    p->num = 100;
    p->age = 18;
    printf("子线程ID:%ld\n", pthread_self());
    // 子线程结束后传出数据
    pthread_exit(p);
    return NULL;
}

int main()
{
    pthread_t tid;
    struct Test  test;
    pthread_create(&tid, NULL, callback, &test);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
    // 主线程结束后不会释放地址空间,即主线程结束不会让子线程结束
    //pthread_exit(NULL);
    // 另外一种可以让子线程执行完毕的方法:让主线程堵塞,等待子线程执行完再结束
    void *ptr=NULL;
    // 这个ptr是一级指针,&ptr取出它的二级指针,返回的值是子线程pthread_exit(&test);这个语句传出的test
    pthread_join(tid,  &ptr);
    struct Test* pt = (struct Test*)ptr;
    printf("年龄为:%d\n", pt->age);
    printf("数字为:%d\n",pt->num);
    return 0;
}

即我们将数据存储到主线程的栈中,在子线程更改这个数据,主线程就能拿到这个数据

4.4 堆区存储数据

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

struct Test
{
    int  num;
    int  age;
};

void* callback(void* arg)
{
    // 此时我们把结构体放在堆区中
    struct Test*  test = (struct Test*)malloc(20);
    for(int i=0; i<5;i++)
    {
        printf("子线程:i=%d\n", i);
    }
    test->num = 100;
    test->age = 18;
    printf("子线程ID:%ld\n", pthread_self());
    // 子线程结束后传出数据
    pthread_exit(test);
}

int main()
{
    pthread_t tid;
    pthread_create(&tid, NULL, callback, NULL);
    for (int i=0; i<5;++i)
    {
        printf("主线程:i = %d\n", i);
    }
    printf("主线程ID:%ld\n", pthread_self());
    // 主线程结束后不会释放地址空间,即主线程结束不会让子线程结束
    // pthread_exit(NULL);
    // 另外一种可以让子线程执行完毕的方法:让主线程堵塞,等待子线程执行完再结束
    void *ptr=NULL;
    // 这个ptr是一级指针,&ptr取出它的二级指针,
    // 返回的值是子线程pthread_exit(&test);这个语句传出的test
    // ptr指向test
    pthread_join(tid,  &ptr);
    // 强制类型转换,将ptr转化为指向pt的指针
    struct Test* pt = (struct Test*)ptr;
    printf("年龄为:%d\n", pt->age);
    printf("数字为:%d\n",pt->num);
    return 0;
}

从上面代码我们可以看到,当我们把子线程要传出来的资源放在堆区时,此时在子线程运行结束后并不会被释放,我们把指向这块内存的地址传给主线程,然后在主线程拿到数据,最后把它free

3.线程分离

在某些情况下,程序中的主线程有属于自己的业务处理流程,如果让主线程负责子线程的资源回收,调用 pthread_join() 只要子线程不退出主线程就会一直被阻塞,主要线程的任务也就不能被执行了。

在线程库函数中为我们提供了线程分离函数 pthread_detach(),调用这个函数之后指定的子线程就可以和主线程分离,当子线程退出的时候,其占用的内核资源就被系统的其他进程接管并回收了。线程分离之后在主线程中使用pthread_join()就回收不到子线程资源了

int pthread_detach(pthread_t thread);

  • 参数
    • thread:子线程ID

下面的代码中,在主线程中创建子线程,并调用线程分离函数,实现了主线程和子线程的分离:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>

// 子线程的处理代码
void* working(void* arg)
{
    printf("我是子线程, 线程ID: %ld\n", pthread_self());
    for(int i=0; i<9; ++i)
    {
        printf("child == i: = %d\n", i);
    }
    return NULL;
}

int main()
{
    // 1. 创建一个子线程
    pthread_t tid;
    pthread_create(&tid, NULL, working, NULL);

    printf("子线程创建成功, 线程ID: %ld\n", tid);
    // 2. 子线程不会执行下边的代码, 主线程执行
    printf("我是主线程, 线程ID: %ld\n", pthread_self());
    for(int i=0; i<3; ++i)
    {
        printf("i = %d\n", i);
    }

    // 设置子线程和主线程分离
    pthread_detach(tid);

    // 让主线程自己退出即可
    pthread_exit(NULL);
    
    return 0;
}

4.其他线程函数

4.1线程取消

线程取消的意思就是在某些特定情况下在一个线程中杀死另一个线程。使用这个函数杀死一个线程需要分两步:

  • 在线程 A 中调用线程取消函数 pthread_cancel,指定杀死线程 B,这时候线程 B 是死不了的
  • 在线程 B 中进程一次系统调用(从用户区切换到内核区),否则线程 B 可以一直运行。

int pthread_cancel(pthread_t thread);

  • 参数
    • thread:要杀死的线程ID
  • 返回值:函数调用成功返回 0,调用失败返回非 0 错误号。

在下面的示例代码中,主线程调用线程取消函数,只要在子线程中进行了系统调用,当子线程执行到这个位置就挂掉了。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <pthread.h>

// 子线程的处理代码
void* working(void* arg)
{
    int j=0;
    for(int i=0; i<9; ++i)
    {
        j++;
    }
    // 这个函数会调用系统函数, 因此这是个间接的系统调用
    printf("我是子线程, 线程ID: %ld\n", pthread_self());
    for(int i=0; i<9; ++i)
    {
        printf(" child i: %d\n", i);
    }

    return NULL;
}

int main()
{
    // 1. 创建一个子线程
    pthread_t tid;
    pthread_create(&tid, NULL, working, NULL);

    printf("子线程创建成功, 线程ID: %ld\n", tid);
    // 2. 子线程不会执行下边的代码, 主线程执行
    printf("我是主线程, 线程ID: %ld\n", pthread_self());
    for(int i=0; i<3; ++i)
    {
        printf("i = %d\n", i);
    }

    // 杀死子线程, 如果子线程中做系统调用, 子线程就结束了
    pthread_cancel(tid);

    // 让主线程自己退出即可
    pthread_exit(NULL);
    
    return 0;
}

这个函数很有意思诶,意思是一个线程调用pthread_cancel这个函数让子线程结束,但是它不会立刻结束,而是子线程做了系统调用后才结束,有点类似,你给别人下毒了,但是这个毒性不会立刻发作,你得满足一些条件(发生了系统调用)才会发生作用,有点类似于七步断肠散

4.2 线程ID比较

在 Linux 中线程 ID 本质就是一个无符号长整形,因此可以直接使用比较操作符比较两个线程的 ID,但是线程库是可以跨平台使用的,在某些平台上 pthread_t 可能不是一个单纯的整形,这中情况下比较两个线程的 ID 必须要使用比较函数,函数原型如下:

int pthread_equal(pthread_t t1, pthread_t t2);

  • 参数:t1 和 t2 是要比较的线程的线程 ID

  • 返回值:如果两个线程 ID 相等返回非 0 值,如果不相等返回 0

5.线程同步

假设有 4 个线程 A、B、C、D,当前一个线程 A 对内存中的共享资源进行访问的时候,其他线程 B, C, D 都不可以对这块内存进行操作,直到线程 A 对这块内存访问完毕为止,B,C,D 中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。 线程对内存的这种访问方式就称之为线程同步,通过对概念的介绍,我们可以了解到所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。

5.1为什么要线程同步

在研究线程同步之前,先来看一个两个线程交替数数(每个线程数 10 个数,交替数到 20)的例子:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>

#define MAX 10
// 全局变量
int number;

// 线程处理函数
void* funcA_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        int cur = number;
        cur++;
        usleep(10);
        number = cur;
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
    }

    return NULL;
}

void* funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        int cur = number;
        cur++;
        number = cur;
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        usleep(5);
    }

    return NULL;
}

int main(int argc, const char* argv[])
{
    pthread_t p1, p2;

    // 创建两个子线程
    pthread_create(&p1, NULL, funcA_num, NULL);
    pthread_create(&p2, NULL, funcB_num, NULL);

    // 阻塞,资源回收
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);

    return 0;
Thread B, id = 139963328038656, number = 1
Thread B, id = 139963328038656, number = 2
Thread A, id = 139963336431360, number = 1
Thread A, id = 139963336431360, number = 2
Thread B, id = 139963328038656, number = 3
Thread A, id = 139963336431360, number = 3
Thread B, id = 139963328038656, number = 4
Thread B, id = 139963328038656, number = 5
Thread A, id = 139963336431360, number = 5
Thread A, id = 139963336431360, number = 6
Thread B, id = 139963328038656, number = 7
Thread A, id = 139963336431360, number = 7
Thread B, id = 139963328038656, number = 8
Thread A, id = 139963336431360, number = 8
Thread B, id = 139963328038656, number = 9
Thread A, id = 139963336431360, number = 9
Thread B, id = 139963328038656, number = 10
Thread A, id = 139963336431360, number = 10
Thread A, id = 139963336431360, number = 11
Thread B, id = 139963328038656, number = 12

通过对上面例子的测试,可以看出虽然每个线程内部循环了 10 次每次数一个数,但是最终没有数到 20,通过输出的结果可以看到,有些数字被重复数了多次,其原因就是没有对线程进行同步处理,造成了数据的混乱。

值得注意的前提是,多线程的运行是无序的,也就是说,我们无法预料哪个线程可以优先抢到cpu时间片,

两个线程在数数的时候需要分时复用 CPU 时间片,并且测试程序中调用了 sleep() 导致线程的 CPU 时间片没用完就被迫挂起了,这样就能让 CPU 的上下文切换(保存当前状态,下一次继续运行的时候需要加载保存的状态)更加频繁,更容易再现数据混乱的这个现象。

CPU 对应寄存器、一级缓存、二级缓存、三级缓存是独占的,用于存储处理的数据和线程的状态信息,数据被 CPU 处理完成需要再次被写入到物理内存中,物理内存数据也可以通过文件 IO 操作写入到磁盘中。

在这里插入图片描述

在测试程序中两个线程共用全局变量 number 当线程变成运行态之后开始数数,从物理内存加载数据,让后将数据放到 CPU 进行运算,最后将结果更新到物理内存中。如果数数的两个线程都可以顺利完成这个流程,那么得到的结果肯定是正确的。

如果线程 A 执行这个过程期间就失去了 CPU 时间片,线程 A 被挂起了最新的数据没能更新到物理内存。线程 B 变成运行态之后从物理内存读数据,很显然它没有拿到最新数据,只能基于旧的数据往后数,然后失去 CPU 时间片挂起。线程 A 得到 CPU 时间片变成运行态,第一件事儿就是将上次没更新到内存的数据更新到内存,但是这样会导致线程 B 已经更新到内存的数据被覆盖,活儿白干了,最终导致有些数据会被重复数很多次。

5.2 线程同步方式

对于多个线程访问共享资源出现数据混乱的问题,需要进行线程同步。常用的线程同步方式有四种:互斥锁读写锁条件变量信号量。所谓的共享资源就是多个线程共同访问的变量,这些变量通常为全局数据区变量或者堆区变量,这些变量对应的共享资源也被称之为临界资源

image-20230113205443561

找到临界资源之后,再找和临界资源相关的上下文代码,这样就得到了一个代码块,这个代码块可以称之为临界区。确定好临界区(临界区越小越好)之后,就可以进行线程同步了,线程同步的大致处理思路是这样的:

  • 在临界区代码的上边,添加加锁函数,对临界区加锁。
    • 哪个线程调用这句代码,就会把这把锁锁上,其他线程就只能阻塞在锁上了。
  • 在临界区代码的下边,添加解锁函数,对临界区解锁。
    • 出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区了。
  • 通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变为串行访问了。

5.3 互斥锁

互斥锁是线程同步最常用的一种方式,通过互斥锁可以锁定一个代码块,被锁定的这个代码块,所有的线程只能顺序执行 (不能并行处理),这样多线程访问共享资源数据混乱的问题就可以被解决了,需要付出的代价就是执行效率的降低,因为默认临界区多个线程是可以并行处理的,现在只能串行处理。

在 Linux 中互斥锁的类型为 pthread_mutex_t,创建一个这种类型的变量就得到了一把互斥锁:

pthread_mutex_t  mutex;

在创建的锁对象中保存了当前这把锁的状态信息:锁定还是打开,如果是锁定状态还记录了给这把锁加锁的线程信息(线程 ID)。一个互斥锁变量只能被一个线程锁定,被锁定之后其他线程再对互斥锁变量加锁就会被阻塞,直到这把互斥锁被解锁,被阻塞的线程才能被解除阻塞。一般情况下,每一个共享资源(静态变量,堆变量)对应一个把互斥锁,锁的个数和线程的个数无关

Linux 提供的互斥锁操作函数如下,如果函数调用成功会返回 0,调用失败会返回相应的错误号:

// 初始化互斥锁
// restrict: 是一个关键字, 用来修饰指针, 只有这个关键字修饰的指针可以访问指向的内存地址, 其他指针是不行的
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
           const pthread_mutexattr_t *restrict attr);
// 释放互斥锁资源            
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 参数
    • mutex: 互斥锁变量的地址
    • attr: 互斥锁的属性,一般使用默认属性即可,这个参数指定为 NULL
// 修改互斥锁的状态, 将其设定为锁定状态, 这个状态被写入到参数 mutex 中
int pthread_mutex_lock(pthread_mutex_t *mutex);

这个函数被调用,首先会判断参数 mutex 互斥锁中的状态是不是锁定状态:

  • 没有被锁定,是打开的,这个线程可以加锁成功,这个这个锁中会记录是哪个线程加锁成功了
  • 如果被锁定了,其他线程加锁就失败了,这些线程都会阻塞在这把锁上,即一直等待
  • 当这把锁被解开之后,这些阻塞在锁上的线程就解除阻塞了,并且这些线程是通过竞争的方式对这把锁加锁,没抢到锁的线程继续阻塞
// 尝试加锁
int pthread_mutex_trylock(pthread_mutex_t *mutex);

调用这个函数对互斥锁变量加锁还是有两种情况:

  • 如果这把锁没有被锁定是打开的,线程加锁成功
  • 如果锁变量被锁住了,调用这个函数加锁的线程,不会被阻塞,加锁失败直接返回错误号
// 对互斥锁解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);

互斥锁使用:我们可以将上面多线程交替数数的例子修改一下,使用互斥锁进行线程同步。两个线程一共操作了同一个全局变量,因此需要添加一互斥锁,来控制这两个线程。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>

#define MAX 50
// 全局变量
int number;

// 定义一个互斥锁
pthread_mutex_t mutex;

// 线程处理函数
void* funcA_num(void* arg)
{

    for(int i=0; i<MAX; ++i)
    {
        pthread_mutex_lock(&mutex);
        int cur = number;
        cur++;
        usleep(10);
        number = cur;
        printf("Thread A, id = %lu, number = %d\n", pthread_self(), number);
        pthread_mutex_unlock(&mutex);
    }

    return NULL;
}

void* funcB_num(void* arg)
{
    for(int i=0; i<MAX; ++i)
    {
        pthread_mutex_lock(&mutex);
        int cur = number;
        cur++;
        number = cur;
        printf("Thread B, id = %lu, number = %d\n", pthread_self(), number);
        pthread_mutex_unlock(&mutex);
        usleep(5);
    }

    return NULL;
}

int main(int argc, const char* argv[])
{
    pthread_t p1, p2;
    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);
    // 创建两个子线程
    pthread_create(&p1, NULL, funcA_num, NULL);
    pthread_create(&p2, NULL, funcB_num, NULL);

    // 阻塞,资源回收
    pthread_join(p1, NULL);
    pthread_join(p2, NULL);
    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);

    return 0;
}

值得注意的是,加锁的覆盖范围应该尽可能将代码块缩小,否则将降低代码的执行效率,只放在执行全局变量的最小代码块范围

5.4 死锁

当多个线程访问共享资源,需要加锁,如果锁使用不当,就会造成死锁这种现象。如果线程死锁造成的后果是:所有的线程都被阻塞,并且线程的阻塞是无法解开的(因为可以解锁的线程也被阻塞了)。

造成死锁的场景有如下几种:

  • 加锁之后忘记解锁
// 场景1
void func()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功, 当前循环完毕没有解锁, 在下一轮循环的时候自己被阻塞了
        // 其余的线程也被阻塞
    	pthread_mutex_lock(&mutex);
    	....
    	.....
        // 忘记解锁
    }
}

// 场景2
void func()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程被阻塞
    	pthread_mutex_lock(&mutex);
    	....
    	.....
        if(xxx)
        {
            // 函数退出, 没有解锁(解锁函数无法被执行了)
            return ;
        }
        
        pthread_mutex_lock(&mutex);
    }
}
  • 重复加锁,造成死锁
void func()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程阻塞
    	pthread_mutex_lock(&mutex);
        // 锁被锁住了, A线程阻塞
        pthread_mutex_lock(&mutex);
    	....
    	.....
        pthread_mutex_unlock(&mutex);
    }
}

// 隐藏的比较深的情况
void funcA()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程阻塞
    	pthread_mutex_lock(&mutex);
    	....
    	.....
        pthread_mutex_unlock(&mutex);
    }
}

void funcB()
{
    for(int i=0; i<6; ++i)
    {
        // 当前线程A加锁成功
        // 其余的线程阻塞
    	pthread_mutex_lock(&mutex);
        funcA();		// 重复加锁
    	....
    	.....
        pthread_mutex_unlock(&mutex);
    }
}
  • 在程序中有多个共享资源,因此有很多把锁,随意加锁,导致相互被阻塞
场景描述:
  1. 有两个共享资源:X, Y,X对应锁A, Y对应锁B
     - 线程A访问资源X, 加锁A
     - 线程B访问资源Y, 加锁B
  2. 线程A要访问资源Y, 线程B要访问资源X,因为资源X和Y已经被对应的锁锁住了,因此这个两个线程被阻塞
     - 线程A被锁B阻塞了, 无法打开A锁
     - 线程B被锁A阻塞了, 无法打开B锁
如何避免死锁?
  • 避免多次锁定,多检查(即人肉看代码)
  • 对共享资源访问完毕之后,一定要解锁,或者在加锁的使用 trylock
  • 如果程序中有多把锁,可以控制对锁的访问顺序 (顺序访问共享资源,但在有些情况下是做不到的),另外也可以在对其他互斥锁做加锁操作之前,先释放当前线程拥有的互斥锁。
  • 项目程序中可以引入一些专门用于死锁检测的模块

5.5 读写锁

读写锁是互斥锁的升级版,在做读操作的时候可以提高程序的执行效率,如果所有的线程都是做读操作, 那么读是并行的,但是使用互斥锁,读操作也是串行的。

读写锁是一把锁,锁的类型为 pthread_rwlock_t,有了类型之后就可以创建一把互斥锁了:

声明代码:pthread_rwlock_t rwlock;

之所以称其为读写锁,是因为这把锁既可以锁定读操作,也可以锁定写操作。为了方便理解,可以大致认为在这把锁中记录了这些信息:

  • 锁的状态:锁定 / 打开
  • 锁定的是什么操作:读操作 / 写操作,使用读写锁锁定了读操作,需要先解锁才能去锁定写操作,反之亦然。
  • 哪个线程将这把锁锁上了

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/160701.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

SAP FICO 成本估算解析

成本估算解析 物料成本构成组件分类&#xff0c;比如下图中的G&#xff08;Overhead&#xff09;、L&#xff08;Subcontracting&#xff09;、E&#xff08;Internal Activity&#xff09;等。 同时它也分不同的视图&#xff0c;每个视图中包含了不同的组件。 对同一个工厂的同…

SpringEvent的使用

步骤&#xff1a;定义事件&#xff0c;继承ApplicationEvent定义监听&#xff0c;要么实现ApplicationListener接口&#xff0c;要么在方法上添加EventListener注解发布事件&#xff0c;调用ApplicationContext.publishEvent()或者ApplicationEventPublisher.publishEvent();1、…

附录C:Standard Parasitic Extraction Format(SPEF)

文章目录C.1 基础(Basics)C.2 格式(Format)C.3 完整语法知乎翻译圣经本附录将介绍标准寄生参数提取格式&#xff08;SPEFSPEFSPEF&#xff09;&#xff0c;它是IEEEStd1481IEEE\ Std\ 1481IEEE Std 1481标准的一部分。 C.1 基础(Basics) SPEFSPEFSPEF允许以ASCIIASCIIASCII交换…

代码整洁之道,好的代码就是为了更美好的生活。

美国童子军有一条简单的军规&#xff1a;让营地比你来时更干净。当梳理代码时&#xff0c;坚守此军规&#xff1a;每次 review 代码&#xff0c;让代码比你发现它时更整洁。 一位大神说过&#xff1a;“衡量代码质量的唯一有效标准&#xff1a;WTF/min”&#xff0c;并配了一个…

Manjaro通过源码编译jdk11

我在编译中遇到的最大的问题就是gcc版本问题&#xff0c;因manjaro是滚动更新&#xff0c;所以gcc也是很新的版本&#xff0c;导致无法编译jdk11 1 下载源码 网上根据关键词查找jdk源码&#xff0c;查找出来很多可以下载源码的链接&#xff0c;这里我们使用github去官方仓库&a…

yolov5-6.0网络添加小目标检测头 TensorRT部署

小目标检测使用TPH-YOLOv5 中的yolov5l-xs-tph.yaml模型&#xff1b;对其中的C3STR替换为C3模块&#xff1b;本文的TensorRT部署是基于yolov5l-xs-tph修改后的模型训练部署&#xff0c;其精度一般主要学习如何TensorRT部署&#xff0c;都是在wang-xinyu基础上简单修改&#xff…

中科大2008年复试机试题

中科大2008年复试机试题 文章目录中科大2008年复试机试题第一题问题描述解题思路及代码第二题问题描述解题思路及代码第三题问题描述解题思路及代码第四题问题描述解题思路及代码第一题 问题描述 一个十进制正整数转换成二进制有多少个1 示例1 输入:10 输出:2解题思路及代码…

数据分析之Excel

自定义格式 一、自定义格式的结构 方式一: 代码结构组成代码码分为四个部分&#xff0c;中间用”;”号分隔&#xff0c;具体如下: 正数格式;负数格式;零格式;文本格式 两个代码部分&#xff0c;则第一部分用于正数和零, 第二部分用于负数 一个代码部分&#xff0c;则该部分将用…

【跟彤砸学编程】——第二课(上)

嗨害嗨大家好&#xff0c;这里是彤砸&#xff01; 今天我们来看看——编程到底是个啥&#xff1f; 上节课滴链接~【跟彤砸学编程】——第一课 程序是什么&#xff1f; 程序数据算法&#xff1b; 数据 生活中到处都是数据—— 1,2,3,是数据 A,B,C是数据 图片是由像素构成&…

【CSDN年度总结】一个四线城市的程序员-苟延残喘

个人简介 首先本人从事软件开发工作近8年&#xff0c;在北京工作5年&#xff0c;2020年1月13号回来老家工作&#xff0c;已经整整3年了&#xff01;&#xff01;&#xff01; 现在做工业大数据方面工作&#xff0c;曾就职于华为做手机系统研发工作。 在一个四线城市坚守做一个…

速看|低代码平台公司流辰信息为实现企业高效办公积极赋能!

流辰信息&#xff0c;历经多年的砥砺奋斗&#xff0c;终于在低代码开发行业斩获越来越多的市场份额&#xff0c;是一家专注于研发低代码产品的低代码平台公司。在持续奋斗的年月中&#xff0c;流辰信息将持续增强研发创新能力&#xff0c;为各企业实现高效办公协作效率强劲赋能…

XC-15媒资管理系统集成

学习页面查询课程计划 需求分析 到目前位置,我们已经可以编辑课程计划信息并且上传课程视频,下一步我们要是心啊在线学习页面动态获取章节对应的视频并且进行播放,在线学习页面所需要的信息有两类,一类是课程计划信息,一类是课程学习信息(视频地址,学习进度等),如下图 在线学…

我与CSDN相识的一年

一、保持初心&#xff0c;笃定前行&#xff0c;回首2022 1、1 保持初心 回想起与CSDN的相识也是有一年多了。最初的相识也是我刚进入大学的时候。那个时候我还是懵懂无知的一个计算机类的大学生。在老师留下的一次任务中&#xff0c;我发现自己有点解决不了&#xff0c;想上网…

如何处理mybatis处理数据库关系中的多对一关系呢?

测试环境的搭建&#xff1a; 导入lombok&#xff1a; 不懂得小伙伴可移步这篇文章 新建实体类&#xff1a; 拿我们日常生活中最常见的举例&#xff1a;多个学生对应一个老师 对于学生这边而言&#xff0c;关联… 多个学生关联一个老师[多对一] 对于老师而言&#xff0c;集…

BGP(边界网关路由协议)小实验

目录实验要求ospf协议启动关于BGP基本知识点BGP关系建立的配置BGP的宣告实验要求 如下实验拓扑&#xff0c;各个自治系统区域和网段已经标注 基本的ip配置&#xff0c;环回配置就不再展示。 要求&#xff1a;除了R5的环回外&#xff0c;其他环回均可以互相访问 ospf协议启动…

C语言进阶——自定义类型——位段、枚举、联合

结构体 目录 一. 位段 1.概念 2.位段的内存分配 3.位段的跨平台问题 4.位段的应用 二. 枚举 1.枚举类型的定义 2.枚举的优点 3.枚举的使用 三. 联合&#xff08;共用体&#xff09; 1.联合类型的定义 ​编辑 2.联合的特点 3. 联合大小的计算 一. 位段 1.…

Git 合并多条commit

文章目录修改前开始修改第一种方式: 命令行第二种方式: Android Studio遇到冲突的解决办法第一种&#xff1a;修改到底,干就完事了第二种&#xff1a;回滚吧&#xff0c;有点慌修改前 开始修改 第一种方式: 命令行 git rebase -i 01fc32484fb2d2229aa20 // 这里对应的是init的…

osg fbo(四),将颜色缓冲区图片中的牛通过shader变绿

osg fbo&#xff08;三&#xff09;中&#xff0c;把整个屏幕变绿了&#xff0c;因为是把shader添加到了颜色缓冲区图片上了。如果只想把牛变绿&#xff0c;就需要把shader添加到原始场景根中。 即 osg::ref_ptr<osg::StateSet> statset_SceneRoot sceneRoot->getOr…

一、Java框架之Spring配置文件开发

文章目录1. 基础概念1.1 Spring Framework1.2 核心概念产生背景IoC、Bean、DI2. 入门案例2.1 普通Maven项目2.2 IoC入门案例2.3 DI入门案例3. bean配置3.1 bean基础配置bean的基础配置bean的别名配置bean的作用范围3.2 bean实例化实例化方法1&#xff1a;构造方法实例化方法2&a…

Chrome浏览器http访问跨越问题与解决方法

一、Chromium 内核&#xff08;<93版本&#xff09;跨越问题解决方法 设置Chrome浏览器的 disable-web-security, 实现跨域访问后端的接口。这个参数可以降低chrome浏览器的安全性&#xff0c;禁用同源策略&#xff0c;利于开发人员本地调试。 解决办法&#xff1a; 新建一…