Linux多线程

文章详细阐述了进程与线程的区别,包括PID和TID,线程控制原语如pthread_self()、pthread_create()、pthread_join()和pthread_exit()的使用,以及线程的原子操作和同步机制。重点讨论了线程ID的类型和线程间的交互,如线程的生命周期管理和信号处理。同时提到了多线程中信号处理的特殊性,以及CAS算法在原子操作中的应用。
摘要由CSDN通过智能技术生成

进程线程数据结构区分

进程pid,线程tid

线程控制原语

c语言链接线程库:-lpthread   c++链接线程库:-pthread

pthread_t类型

        线程id的类型是pthread_t它只在当前进程中保证是唯一的,在不同的系统中thread_t这个类型有不同的实现,它可能是一个整数值也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用printf打印,调用pthread_self(3)可以获得当前线程的id。linux的unbuntu系统下为无符号整数(%lu)其他系统中可能是结构体实现

pthread_self函数

获取线程ID。其作用对应进程中 getpid() 函数。

        pthread_t pthread_self(void); 返回值:成功:0; 失败:无!

        线程ID:pthread_t类型,pthread_t的本质:在Linux下为无符号整数(%lu)其他系统中可能是结构体实现

        pthread_t:当前Linux中可理解为:typedef  unsigned long int  pthread_t;

        线程ID是进程内部,识别标志。(两个进程间,线程ID允许相同)

pthread_create函数

创建一个新线程。

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

        返回值:成功:0; 失败:错误号 -----Linux环境下,所有线程特点,失败均直接返回错误号。

        注:这里返回的是错误号,所以用stderror(num);来打印错误信息。还有一种常用模式是失败返回-1,错误号赋值给全局变量errno中,然后通过perror打印错误信息。这里是前者。

        参数:

        参数1:传出参数,保存系统为我们分配好的线程ID

        参数2:通常传NULL,表示使用线程默认属性。若想使用具体属性也可以修改该参数。

        参数3:函数指针,指向线程主控函数(线程体),该函数运行结束,则线程结束。

        参数4:线程主函数执行期间所使用的参数。

        1. void* arg不多余吗?start_routine不是有参数void*?

        答:不多余,start_routine这个函数指针是由内核调用的,内核不知道参数是啥,你需要把参数通过void* arg传过去,然后内核调用start_routine(arg);

        2. start_routine返回值其它线程可以调用pthread_join得到start_routine的返回值。

        调用pthread_create的进程退化为主线程,然后其他的创建出来的线程是子线程。

        3. start_routine的参数怎么用,传什么?

        结论:通常传堆区开辟出来的空间的指针,或者值传递,或者传NULL

        线程设计的初衷:栈区独享,其他区域共享。

        所以不要在栈上int a=10;然后把a的地址&a作为参数传给void* arg,这样就违背了设计的初衷,另一个线程可以访问到该线程上的a变量(通过地址间接访问得到)。

  •         线程不像进程,还得把共享数据用mmap开辟到内核区,线程就开辟到堆区就能实现数据共享,即多个线程操作堆空间上的同一数据。所以把堆区指针传给void* arg。
  •         有时候就是多个线程不想操作同一数据,比如有个需求,传个参数i,子线程通过i知道自己是第几个创建出来的线程。这时候就需要值传递。因为你把地址传过去,主线程的i早就++操作了,子线程再一打印i,那就不是想要的序号了。

        代码如下:

void* start_routine(void* arg){//子线程
    int i=(int)arg;
    printf("i was created at %d\n",i);
}
void test01(){
    pthread_t tid;
    for (size_t i = 0; i < 5; i++)
    {
        pthread_create(&tid,NULL,start_routine,(void*)i);//别整成(void*)&i,这就是地址传递了

    }
    sleep(2);
}

        总结:多个线程想操作同一个数据,开辟空间到堆空间,然后传给void* arg;如果各是各的,谁也别干扰谁,那就值传递。

        需要注意的是:有些编译器可能不支持int a=(int)arg这种,比如vs。linux下int4字节,指针类型8字节包括void*,long类型8字节。long a=(long)arg;不报错,所以先把arg转为long,然后再转int。int x = (int)(long)arg;

----------------------------------------------------------------------------------------------------------------------

还有同学即有传上述序号的需求,又有传其他共享数据的需求。

        暂时没啥太好的办法,不过全局变量是共享的,只能把共享数据放在全局。然后start_routine值传递(void*)i;

pthread_exit函数

将单个线程退出

void pthread_exit(void *retval); 参数:retval表示线程退出状态,通常传NULL。

retval同样会被pthread_join接收

pthread_join函数

阻塞等待线程退出并回收,获取线程退出状态 ,对应进程中 waitpid() 函数。

int pthread_join(pthread_t thread, void **retval); 成功:0;失败:错误号

只能回收指定线程

参数:thread:线程ID (【注意】:不是指针);retval:存储线程结束状态传出参数

对比记忆:

进程中:main返回值、exit参数-->int;等待子进程结束 wait 函数参数-->int *

线程中:线程主函数返回值、pthread_exit-->void *;等待线程结束 pthread_join 函数参数-->void **

创建10个子线程并回收,代码如下:

void *thread_function(void *arg) {
    int thread_id = (int)arg;
    printf("Thread %d is running.\n", thread_id);
    // do some work here 
    printf("Thread %d is done.\n", thread_id);
    pthread_exit((void*)thread_id);
}

int main() {
    pthread_t thread[10];
    int thread_args[10];
    int i;
    void* ret;//注:这里只能用void*,不能用int ret;具体原因很复杂

    // create threads
    for (i = 0; i < 10; i++) {
        thread_args[i] = i;
        pthread_create(&thread[i], NULL, thread_function, (void*)i);
    }

    // wait for threads to finish
    for (i = 0; i < 10; i++) {
        pthread_join(thread[i], (void**)&ret);
        printf("ret=%d\n",(int)ret);
    }

    printf("All threads are done.\n");

    return 0;
}

pthread_join接收返回值的时候,只能声明为void* ret

int ret;
pthread_join(tid,(void**)&ret);

sizeof(int)=4;sizeof(void*)=8

如果这么做,把ret地址传过去,然后pthread_join将会把8字节的数据写入ret处,ret本身就是个int类型,在栈区存储。然后你往里写了8字节,破坏了栈的结构。

pthread_detach函数

实现线程分离 

        int pthread_detach(pthread_t thread); 成功:0;失败:错误号

        线程分离状态:指定该状态,线程主动与主控线程断开关系。线程结束后,其退出状态不由其他线程获取,而直接自己自动释放pcb。网络、多线程服务器常用。

        进程若有该机制,将不会产生僵尸进程。僵尸进程的产生主要由于进程死后,大部分资源被释放,一点残留资源仍存于系统中,导致内核认为该进程仍存在。

        也可使用 pthread_create函数参2(线程属性)来设置线程分离。

      能对一个已经处于detach状态的线程调用pthread_join如果对该线程调用了detach函数,就不能再join回收该线程了,这样的调用将返回EINVAL错误。

pthread_cancel函数

杀死(取消)线程 其作用,对应进程中 kill() 函数。

        int pthread_cancel(pthread_t thread); 成功:0;失败:错误号

        【注意】:线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点(检查点)

        取消点:通常是一些系统调用,在系统调用结束后,返回用户太时,会检查一下是否要杀死该线程,可粗略认为一个系统调用(进入内核)即为一个取消点。如线程中没有取消点就不不会杀死线程,可以通过调用pthread_testcancel ()函数自行设置一个取消点

        被取消的线程, 退出值定义在Linux的pthread库中。数PTHREAD_CANCELED的值是-1。可在头文件pthread.h中找到它的定义:#define PTHREAD_CANCELED ((void *) -1)因此当我们对一个已经被取消的线程使用pthread_join回收时得到的返回值为-1

pthread_equal函数

比较两个线程ID是否相等。

目前都是lu类型可以直接等号比较,以后pthread_t可能会变成结构体

int pthread_equal(pthread_t t1, pthread_t t2);

有可能Linux在未来线程ID pthread_t 类型被修改为结构体实现。

线程同步处理

略,网上资料很多。

线程信号处理

        接下来我们聊一个比较有意思的话题。进程对信号的处理大家可能都非常清楚了。但是线程该如何处理信号呢?

        首先先给大家上一些我经过测试的小结论。此时先不考虑线程库的pthread_kill sigwait pthread_sigmask这些函数。用的都是kill sigprocmask这些进程级别的api

        结论:

  •         不管有多少个子线程注册了信号捕捉函数,在捕捉函数内部调用pthread_self发现,处理该信号的都是主线程。
  •         只要有一个子线程注册了信号捕捉函数,那么该信号就会被捕捉。而且是主线程进行的。除非主线程屏蔽了该信号。那么才会由没屏蔽该信号的子线程去处理。
  •         而且信号捕捉函数只会由一个线程处理一次,要么主线程处理,主线程屏蔽了就选一个子线程处理,反正就处理一次。
  •         如果主线程和子线程对同一信号的处理方式都不同,以最后一次注册为准。一个信号只对应一种处理方式。所有线程共享。

总结:信号捕捉函数是所有线程共享的,以最后一次注册为准(不存在这个线程是这么处理这个信号,那个线程是那么处理这个信号)。

        那么线程库又设计一套api干什么呢?

        在进程环境中,对信号的处理是,先注册信号处理函数,当信号异步发生时,调用处理函数来处理信号。它完全是异步的(我们完全不知到信号会在进程的那个执行点到来!)。然而信号处理函数的实现,有着许多的限制;比如有一些函数不能在信号处理函数中调用;再比如一些函数read、recv等调用时会被异步的信号给中断(interrupt),因此我们必须对在这些函数在调用时因为信号而中断的情况进行处理(判断函数返回时 enno 是否等于 EINTR)

        但是在多线程中处理信号的原则却完全不同,它的基本原则是:将对信号的异步处理,转换成同步处理,也就是说用一个线程专门的来“同步等待”信号的到来,而其它的线程可以完全不被该信号中断/打断或者捕捉从而跳转到捕捉函数。(说白了就是其他线程就不受这些信号的影响)。

        想实现这种功能的核心就是sigwait函数,以及sigwaitinfo和sigtimedwait这些。

sigwait函数

#include <signal.h>

int sigwait(const sigset_t *set, int *sig);

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

参数:使用一个信号集作为他的参数,并且在集合中的任一个信号发生时设置该信号值(参数2),解除对该信号的屏蔽(在调用sigwait应该先把相应信号屏蔽),然后可以针对该信号进行一些相应的处理,处理完自动又把相应信号屏蔽。解除屏蔽->执行捕捉函数->恢复屏蔽

注:调用sigwait同步等待的信号必须在调用线程中被屏蔽,并且通常应该在所有的线程中被屏蔽(这样可以保证信号绝不会被送到除了调用sigwait的任何其它线程)。

注:在多线程代码中,总是使用sigwait或者sigwaitinfo或者sigtimedwait等函数来处理信号。而不是signal或者sigaction等函数。sigaction注册的捕捉函数是所有线程共享的,以最后一次注册为准。

sigset_t set;
int signo;
while(1){
    sigwait(&set,&signo);//阻塞在这里等信号
    //收到该信号解除屏蔽后进行判断
    if(signo==SIGINT){
    //进行处理,就不再用sigaction那一套了。
    }else if(signo==SIGxxx){
        //处理SIGXXX
    }else if(){
    
    }
        ....
}

pthread_kill函数

        在多线程程序中,一个线程可以使用pthread_kill对同一个进程中指定的线程(包括自己)发送信号。

        注意在多线程中一般不使用kill函数发送信号,因为kill是对进程发送信号,结果是:正在运行的线程会处理该信号,如果该线程没有注册信号处理函数,而且默认处理还是终止进程,那么进程直接就退出了。

pthread_sigmask函数

        经本人测试,sigprocmask设置屏蔽后,子线程依旧可以继承信号屏蔽字。目前看来没啥区别。

总结

        多线程处理信号采用sigwait方式,不用sigaction方式。

        就是单一开辟一个线程,让该线程同步的处理所有信号。其他子线程不受这些信号的影响,该干啥干啥。

        多线程处理信号采用的是sigwait同步处理信号的方式,而多进程采用的是sigaction异步处理信号的方式。

        在进程环境中,进程想不被信号所打扰,又有信号这种机制的需求,这时候可以考虑开一个子线程,让子线程同步处理一些信号。

        pthread_kill直接发给调用sigwait的线程,别用kill。

线程的原子性

互斥锁实现,CAS指令实现。

CAS(Compare and Swap)是一种原子操作,用于在多线程编程中实现同步。它通常用于解决并发访问共享资源时的竞争条件问题。在Linux汇编语言中,CAS指令对应的是`lock cmpxchg`指令,它可以比较内存地址中的值与寄存器中的值,如果相等则将新的值写入内存地址,并返回旧的值;否则不做任何操作,并返回当前内存地址中的值。这个过程是原子性的,因此可以避免数据竞争的问题。需要注意的是,CAS指令只能用于支持该指令的处理器上,并且其使用需要特殊的汇编语法和编译选项。

lock汇编指令

lock作为前缀,再加一些汇编指令,形成了原子操作。

原子自增操作

#include <stdatomic.h>

// 定义一个原子变量
atomic_int var = ATOMIC_VAR_INIT(0);

// 原子自增函数
void atomic_increment()
{
    atomic_fetch_add(&var, 1);
}
#include <stdio.h>
#include <stdatomic.h>

int main() {
    atomic_int count;
    atomic_init(&count, 0); // 初始化为 0

    printf("Initial value: %d\n", atomic_load(&count));

    return 0;
}

线程的可见性

        一个线程对数据做了修改,其他线程能实时的读取到修改后的结果。否则会导致读到脏数据的问题(旧数据)。

        导致不可见的原因:

        1. 编译器自作主张的优化代码。导致修改了变量的值,代码逻辑上也没重新去看一下值变没变。

while(flag){
    do someting ...
}

//优化为

if(flag){

    while(true){
        
    }
}

//优化的目的,避免循环读取flag的值

        2. 可能与计算机的存储系统有关

        一个cpu上的数据可能在寄存器上,一个cpu把值修改了,另一个cpu上的线程读不到另一个cpu上的数据。

        读写操作如果在cache里命中,直接在cache里搞,cache里的东西不会立即刷新到主存中。存在缓存和主存不一致的问题。

多线程的有序性

重排序:一个语句依赖上一个语句的执行结果,那么编译器不会重排序。如果两个语句没有依赖关系,那么有可能发生重排序。这时对单线程而言,多线程的话就需要注意一下这个重排序问题。

指令重排序

        指令重排序是编译器或处理器为了优化程序性能而对指令执行顺序进行的一种技术。在不改变程序语义的前提下,可以重新排列指令以使程序更快地执行。一般单线程完全不用担心,多线程需要通过内存屏障等同步机制来限制指令重排序。

内存屏障

        内存屏障可以分为多种类型,包括读屏障、写屏障、全屏障等。读屏障确保在读取某个内存位置之前,所有之前的写操作都已经完成。写屏障确保在写入某个内存位置之后,所有之前的写操作都已经提交到内存中。全屏障则同时包含了读屏障和写屏障的功能,它可以保证在全屏障之前的所有操作都已经完成,并且在全屏障之后的所有操作都还没有开始执行。

        GCC 提供了一些内置函数来实现内存屏障,例如 __sync_synchronize()__sync_thread_fence()__sync_fetch_and_add() 等。这些函数会告诉编译器在它们前后的代码不能被乱序执行或优化。

        C11 标准引入了 <stdatomic.h> 头文件,其中定义了一些标准原子类型和操作函数。在这个头文件中也定义了 memory_order 枚举类型,可以用它来指定内存屏障的类型,例如 memory_order_acquire、memory_order_release、memory_order_seq_cst 等。

        实现内存屏障最底层的方式就是使用汇编语言。不同CPU架构下实现略有差异,例如 X86 架构下可以使用 mfence 指令来进行屏障操作。

void memory_barrier() {
    __sync_synchronize();
}

//上述代码中,__sync_synchronize() 函数会在其前后插入一条 full memory barrier 指令,确保在它前面的所有读写操作都完成之后再执行其后面的操作,并防止编译器将其前后的代码进行重排。

存储子系统重排序

由于计算机体系结构的限制以及操作系统和编译器的实现策略,C语言程序的存储子系统可能会出现重排序的情况。

C语言并没有定义存储子系统的具体实现,因此存储子系统的重排序问题需要针对具体的编译器和操作系统进行考虑。

例如,编译器可能会为了优化性能而将变量或数据结构从内存移动到寄存器中,或者采用某种缓存策略来提高访问速度。操作系统也可能会通过虚拟内存技术将内存中的数据换入换出到磁盘上,以保证系统资源的有效利用。

再例如两个连续的读取内存操作,即便是没发生指令重排,A先执行完,但是读到了cpu的缓冲当中,B再执行,读到了主存的相应位置,之后过了一阵,A又把缓冲的数据写入主存。这看起来像是B先执行了一样,其实还是A先执行的。

//thread1
bool flag = true;
sleep(20);
flag = false;
//thread2
while(flag){
    do someting ...
}

false写指令已经执行了,但是存储子系统仅仅写入到了缓冲中,并没有进入到主存。所以thread2并没有
读取到修改后的flag值。

c语言volatile关键字

在C语言中,`volatile`是一种类型修饰符,用于告诉编译器所修饰的变量可能会被意外地修改,因此编译器不应该进行优化或缓存操作,但是不具备原子性。具体来说,`volatile`关键字的作用包括:

1. 防止编译器优化:编译器在优化代码时可能会将一些代码优化掉,但是如果这些代码对于程序运行结果有影响,就需要使用`volatile`关键字避免优化。

2. 确保变量始终从内存中读取:如果一个变量被声明为`volatile`,那么每次访问它都将从内存中读取最新的值,而不是使用寄存器中的缓存值。

3. 保证多线程下变量的可见性:当多个线程同时访问同一个变量时,由于编译器和处理器的优化,某些线程可能无法看到其他线程对该变量的修改,导致程序出现问题。如果将该变量声明为`volatile`,则可以确保所有的线程都能够看到最新的值。

总之,`volatile`关键字在一些特殊情况下非常有用,比如硬件编程、多线程编程等场景。但是,在普通的程序开发中,不需要过度使用`volatile`,否则会导致程序性能下降。

stdatomatic.h中的memory_order

memory_order:原子操作内存序

stdatomic.h头文件中的memory_order枚举类型用于指定原子操作中的内存序(memory order),也就是编译器和硬件如何处理并发访问同一内存地址的情况。

在多线程程序中,由于不同线程可能同时读写共享变量,因此需要使用原子操作来确保操作的正确性和预期。内存序指定了原子操作中各个操作之间的顺序关系,以及它们对其他线程的可见性。

stdatomic.h中定义了以下几种memory_order选项:

1. memory_order_relaxed:最轻量级的内存序,无需任何同步。仅保证操作的原子性,但不保证任何先后顺序或可见性。

2. memory_order_acquire:确保该操作之前的所有读操作都完成,并使该操作和后续读操作具有顺序关系。这种内存序主要用于读取共享变量的值,并确保读操作之前和之后不会受到其他线程写入的干扰。

3. memory_order_release:确保该操作之后的所有写操作都不会被重排序到该操作之前,并使该操作和前面的写操作具有顺序关系。这种内存序主要用于更新共享变量的值,并确保写操作之前和之后不会受到其他线程读取的干扰。

4. memory_order_acq_rel:同时拥有memory_order_acquire和memory_order_release的特性,用于既需要读取共享变量的值,又需要更新共享变量的情况。

5. memory_order_seq_cst:最强的内存序,同时保证操作的原子性、所有线程观察到相同的顺序关系、所有线程能够看到该操作之前和之后的所有操作。它是其他内存序的超集。

使用不当的内存序可能会导致程序出现隐蔽的错误或性能下降。因此,在编写多线程程序时应该仔细考虑每个原子操作所需的内存序,并根据实际情况选择合适的选项。

CAS算法实现原子操作

原子操作实现方法:

        底层:LOCK指令,CMPXCHG指令来实现

        应用层:各种锁,手写CAS(模拟),atomatic(底层还是lock)

CAS算法:不保证可见性,共享变量用volatile修饰。CAS指令首先比较一个内存地址中的值和一个期望值是否相等,如果相等,则将该地址中的值替换为一个新值,并返回“成功”。如果不相等,则什么都不做,并返回“失败”。


volatile static int count = 0;
pthread_mutex_t mutex;
int compareandswap(int expectvalue, int newvalue)
{
    // 加锁
    pthread_mutex_lock(&mutex);
    if (expectvalue == count)
        count = newvalue;
    else
    {
        // 解锁
        pthread_mutex_unlock(&mutex);
        return 0;
    }
    // 解锁
    pthread_mutex_unlock(&mutex);
    return 1;
}

void CASincrease()
{
    int oldvalue;
    int newvalue;
    do
    {
        oldvalue = count;
        newvalue = oldvalue + 1;
    } while (!compareandswap(oldvalue, newvalue));
}

void *thread_work(void *arg)
{
    for (size_t i = 0; i < 10000; i++)
    {
        CASincrease();
    }
}

void test05()
{
    if (pthread_mutex_init(&mutex, NULL) != 0)
    {
        fprintf(stderr, "Error initializing mutex\n");
        exit(1);
    }
    pthread_t tids[MAXTHREADSIZE];
    for (size_t i = 0; i < MAXTHREADSIZE; i++)
    {
        pthread_create(&tids[i], NULL, thread_work, NULL);
    }
    for (size_t i = 0; i < MAXTHREADSIZE; i++)
    {
        pthread_join(tids[i], NULL);
    }
    printf("count = %d\n", count);
    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
}

CAS的ABA问题

可以通过时间戳来解决。value值由10改为11再改为10,能说他和预期值一样吗?通过加时间戳区分第一个10和第二个10

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值