您的位置:首页 > 房产 > 家装 > 【unix高级编程系列】线程

【unix高级编程系列】线程

2024/12/27 6:44:06 来源:https://blog.csdn.net/xieyihua1994/article/details/141997652  浏览:    关键词:【unix高级编程系列】线程

引言

我们知道unix进程中可以有多个线程,进程中的线程可以访问该进程的所有组成部分。并且CPU的调度单元就是线程。这就面临一个问题:当进程中的临界资源需要在多个线程中共享时,如何解决一致性问题?

本文将从线程的概念、线程的使用方式、unix提供哪些方式解决一致性问题进行介绍,加深对线程的理解。

线程概念

线程的优点:

  • 简化代码结构。比如在业务上为每种事件类型分配单独的处理线程,可以简化处理异步事件的代码。
  • 提高程序的吞吐量以及响应时间。
  • 对进程的共享资源访问更加的方便。

线程的资源:

每个线程除了共享进程的所有组成部分,也包含线程执行所必须信息:线程ID、一组寄存器、栈、调度优先级和策略、信号屏蔽字、error变量以及线程私有数据

线程的使用

线程ID

每一个进程有一个进程ID,每个线程也有一个线程ID。我们可以通过pthread_self获取线程ID。

#include <pthread.h>
pthread_t pthread_self(void);// 返回值:调用线程的线程ID

打印线程的ID,在程序调试阶段有时是非常有用的。

线程创建

#include <pthread.h>
int pthread_create(pthread_t *restrict tidp,const pthread_attr_t *restrict attr,void *(*start_rtn)(void*),void *restrict arg);
// 返回值:若成功返回0 ,不成功,返回错误编码
  • tidp。当线程创建成功后,tidp 会被设置为新创建子线程的线程ID。(《UNIX环境高级编程 第3版》 似乎描述错误了。
  • attr参数用于设置线程的属性。比如:设置线程的栈大小(默认8MB)线程的调度策略及调度参数和优先级等。
  • start_rtn是新创建线程的运行开始地址。
  • arg 是传给子线程的参数。如果需要向子线程传递两个以上的线程,需要将这些参数放到一个结构体中,然后将这个结构体地址传入(最好是堆内容,由子线程管理,释放)。

注:线程创建时并不能保证哪个线程会先执行:是新创建的线程,还是调用线程。

如下列示例就存在隐患:

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>void* my_thread(void * param)
{int num = *param; printf("param = %d\n",num);return NULL;
}int demo()
{pthread_t tidp;int num = 5;if(pthread_create(&tidp,NULL,my_thread,&num) != 0){printf("create pthread failed");}return 0;
}

分析:

  1. demo函数创建子线程成功后,子线程中的入参param 设置为demo函数局部变量 num的地址。
  2. 此时CPU优先调度 demodemo 函数执行完成,进行了栈回收。此时num的地址空间可能就会被修改。
  3. CPU再次调用到子线程my_thread。此时访问num地址的内容,就与预期不符。

简单的修改方式:传入num的值。

线程终止

在进程控制章节,我们了解到在代码的任何地方调用exit_Exit_exit,那么整个进程就会终止。那么是否可以在不停止进程的情况下,停止对应的进程呢。unix提供了三种方式:

  1. 线程可以简单地从启动例程中返回,返回值是线程的退出码。
  2. 线程可以被同一进程中的其他线程取消。
  3. 线程调用pthread_exit

这里着重介绍一下第二、三种方式:

#include <pthread.h>
int pthread_canncel(pthread_t tid);
// 返回值:若成功给,返回0;否则,返回错误编号

进程可以通过pthread_cancel接口向指定同进程中的线程发起退出请求。但是它并不等待线程终止。而线程可以选择忽略此请求或控制如何被取消。

#include <pthread.h>
void pthrad_exit(void *rval_ptr);int pthread_join(pthread_t thread, void **rval_ptr);

线程可以通过pthread_exit接口退出线程,其中rval_ptr是退出码,其它进程可以通过pthread_join捕获退出码,但是调用线程在指定线程没有退出前,会一直处于阻塞状态

线程清理处理程序

在进程环境章节,我们介绍到exit函数在进程退出时,会先执行终止处理程序(类似C++中的析构函数),再清理标准I/Oatexit提供了注册该处理程序的能力。类似的,线程也可以注册退出时调用的函数。

#include <pthread.h>
void pthread_cleanup_push(void (*rtn)(void*), void *arg);
void pthread_cleanup_pop(int execute);

注:线程清理处理程序只有两种情况下触发:

  1. 在调用pthread_exit主动退出时;
  2. 响应其他线程的取消请求时。

即:线程正常从启动例程中return 退出是不会触发 线程清理处理程序

线程分离

在进程环境章节,我们了解到子进程退出时,会在内存中保留退出状态,等待父进程通过waitpid获取,否则会一直存在,成为僵尸进程,造成资源浪费。类似的,线程退出时,也会将终止状态保存着,等待其他进程调用pthread_jion进行回收,否则同样也会造成资源浪费。

但是调用pthread_jion可能会造成调用线程一直阻塞,与我们业务设计不符。若我们对线程退出状态不关心的话,可以将其进行线程分离。若线程已经被分离,线程的底层存储资源在线程终止时立即被回收。

#include <pthread.h>
int pthread_detach(pthread_t tid);

一致性问题探讨

当多个线程共享同一块内存时,就需要考虑数据一致性问题。多线程访问共享内存的场景可以分为以下几个场景。

  1. 共享变量(比如全局变量),仅由一个线程访问,其他线程不会读取和修改。这种场景就不存在问题
  2. 多线程对共享变量只存在读取操作,不会修改。这种场景不存在问题
  3. 当多线程访问一个共享变量,并且其中有一个以上的线程可以修改变量。则存在一致性问题

一致性问题存在的根因:修改全局变量的操作往往不是原子操作,存在多个存储器访问周期。当其它线程读取时,可能在其修改周期内访问,则会造成读取异常值。举个例子:

#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
long num = 0x00000000;
void* my_thread(void * param)
{printf("num = 0x%0lx\n",num);return NULL;
}int main()
{pthread_t tidp;if(pthread_create(&tidp,NULL,my_thread,NULL) != 0){printf("create pthread failed");}num = 0xffffffff;pthread_join(tidp,NULL);return 0;
}

分析:

  1. 主进程修改num 变量,可能存在需要两个存储器周期(num正好分配在两个物理页中)。
    a. 将第一个页中的num低32bit 设置为0xffff
    b. 将第二物理页中的num 高32bit设置为0xffff
  2. 正如上节讨论的,CPU对线程的调用顺序是随机的,因此子线程在访问num变量时,可能是主线程刚刚更新一个物理页中的数据。此时子线程得到的值就是0x0000ffff。这是就出现了异常,num的业务含义可能只有0和0xffffffff。但是此时子线程获取到0x0000ffff,则会造成程序异常。

注:若修改操作是原子操作,就不存在竞争问题。比如C++中的原子变量,就可以避免多线程访问的一致性问题

C语言并没有原子变量,但是unix也提供了多种方式,在多线程访问共享变量时,如何保持同步。比如互斥量、读写锁、条件变量、自旋锁、屏障。

互斥量

互斥量使用pthread_mutex_t数据类型表示。常见接口如下:

#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量可以通过上述接口进行初始化。也可以静态初始化,设置为常量PTHREAD_MUTEX_INITIALIZER

#include <pthread.h>
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

其中,若不希望线程被阻塞,可以使用pthread_mutex_trylock,互斥量若未被锁住,则返回0,并锁住互斥量;若互斥量已经被锁住,则返回EBUSY

注:若同一个线程,连续对互斥量加锁两次以上,线程自身则会陷入死锁。并且其它线程也无法再次获取到互斥量,导致整个业务进入死锁状态

#include <pthread.h>
#include <time.h>
int pthread_mutex_timelock(pthread_mutex_t *mutex,const struct timespec *restrict tsptr);

pthread_mutex_timelock尝试获取互斥量时,若互斥量已经被锁住,则进行阻塞。直到其它线程将互斥量释放,获取到互斥量。或达到超时,返回ETIMEDOUT(超时指愿意等待的绝对时间,即在时间X之前可以阻塞等待,而不是等待Y秒)这就存在一个问题,若系统的时间变更了,则会出现意料之外的情况。

读写锁

读写锁和互斥量类似,不过读写锁在一些场景下,提供了更高的并行性。那是因为读写锁的特性决定的,读写锁有三种状态:

  1. 读模式加锁状态。当处于该状态时,所有试图以读模式对它进行加锁的线程,都可以得到访问全。但是任何以写模式加锁的线程都会被阻塞。
  2. 写模式加锁状态。当处于该状态时,所有试图对这个锁加锁的线程都会被阻塞。
  3. 不加锁状态。任何加锁请求都可以满足。

注:针对第一种状态,若当前已经处于读模式加锁状态,下一个线程写模式获取锁,会被阻塞。并且后续以读模式获取锁的线程也会被阻塞。其目的是防止读模式锁长期占用

由于读写锁的特性,非常适合共享变量读取次数远远大于修改的场景。

#include <pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

读写锁在使用之前必须初始化,在释放底层内存之前,必须要销毁。

#include <pthread.h>
#include <time.h>
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);    // 读模式获取锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);    // 写模式获取锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);    // 释放锁int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);    // 读模式获取锁
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);    // 写模式获取锁int pthread_mutex_timerdlock(pthread_rwlock_t *restrict rwlock,const struct timespec *restrict tsptr);
int pthread_mutex_timewrlock(pthread_rwlock_t *restrict rwlock,const struct timespec *restrict tsptr);

条件变量

条件变量是线程可用的另一种同步机制,条件变量本身需要使用互斥量保护。因此两者需要一同使用。

pthread_cond_t 数据类型表示条件变量,它可以用两种方式进行初始化。

  1. 常量PTHREAD_COND_INITAIALIZER赋值给静态分配的条件变量
  2. 动态分配,再使用pthread_cond_init初始化
#include <pthread.h>
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t * restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
#include <pthread.h>
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutet_t *restrict mutex);int pthread_cond_timewait(pthread_cond_t *restrict cond,pthread_mutet_t *restrict mutex,const struct timespec *restrict tsptr);

这里的互斥量是用于对条件的保护。调用者需要将锁住的互斥量传给函数,函数然后回自动把调用线程放到等待条件的线程列表上,对互斥量解锁,等待条件变量满足。将这个流程分步骤理解如下:

  1. 获取互斥量
  2. 将条件变量放到等待条件的线程列表上
  3. 解锁互斥量。其它线程可以获取互斥量
  4. 线程阻塞,等待条件满足
  5. 当条件满足时,线程会再次尝试获取互斥量
pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;
//伪代码如下:pthread_mutext_lock(&qlock);pthread_cond_wait(&qready,&qlock);/* 临界资源处理*/pthread_mutext_unlock(&qlock);

通知条件已满足,有两个接口。

#include <pthread.h>
int pthread_cond_signal(pthread_cond_t  *cond);
int pthread_cond_broadcast(pthread_cond_t  *cond);

自旋锁

自旋锁与互斥量类似,但是它不是通过休眠使线程阻塞,而是在获取锁之前一致处于忙等待阻塞状态。

在CPU性能优化——“瑞士军刀“章节中,我们了解到上下文切换的概念。一旦线程阻塞进入休眠,再次运行到此线程时,需要将该线程的上下文恢复,这个切换的过程是比较耗时的。

而自旋锁的特性,决定了:若明确等待锁的时间小于上下文切换的损耗,则在性能上获得提升。因此自旋锁的使用场景有:

  • 短时间锁定。当预计线程持有锁的时间非常短时,使用自旋锁可能更有效。因为自旋锁避免了线程切换的开销,在等待锁释放的过程中,线程仍然在运行。
  • 多核处理器:在多核处理器上,如果锁被持有的时间很短,让等待的线程在另一个核心上自旋,可能比将其挂起和稍后重新调度更高效。
  • 低延迟要求:在需要低延迟响应的环境中,自旋锁可以减少线程因等待锁而被挂起的时间,从而降低响应时间。
  • 内核态同步:在操作系统内核中,自旋锁经常用于同步对共享资源的访问,因为内核通常不能承受线程切换带来的开销。
  • 无锁数据结构:在实现无锁(lock-free)或无等待(wait-free)数据结构时,自旋锁可以作为辅助工具,帮助确保在修改数据结构时的一致性。
  • 高性能计算:在高性能计算(HPC)应用中,为了减少同步开销,可能会使用自旋锁来同步对共享资源的访问。
  • 频繁访问的共享资源:当共享资源被频繁访问,且每次访问的时间都很短时,自旋锁可以减少线程切换的次数,提高效率。
#include <pthread.h>
int pthread_spin_init(pthread_spinlock_t *lock, int psshared);
int pthread_spin_destroy(pthread_spinlock_t *lock);int pthread_spin_lock(pthread_spinlock_t *lock);
int pthread_spin_trylock(pthread_spinlock_t *lock);
int pthread_spin_unlock(pthread_spinlock_t *lock);

屏障

屏障是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程达到某点,然后从该点继续执行。

#include <pthread.h>
int pthread_barrier_init(pthread_barrier_t * restrict barrier,const pthread_barrierattr_t *restrict attr,unsigned int count);int pthread_barrier_destroy(pthread_barrier_t *barrier);

其中count参数指定,在允许所有线程继续运行前,必须达到屏障的线程数目。

#include <pthread.h>
int pthread_barrier_wait(pthread_barrier_t *barrier);

线程在调用pthread_barrier_wait接口时,会进行屏障计数,若未满足条件,则会进入休眠状态。若该线程是最后一个调用pthread_barrier_wait接口的线程,所有线程都会被唤醒。

总结

本文主要介绍了Unix环境下多线程编程的概念、使用方式以及如何解决一致性问题。

线程概念:

  • 线程是进程内的一个执行流,具有自己的线程ID、寄存器、栈等资源,但与同进程的其他线程共享进程资源。
  • 线程的优点包括简化代码结构、提高程序吞吐量和响应时间,以及对共享资源的便捷访问。

线程的使用:

  • 线程的创建、终止、清理处理程序、分离等操作方法。
  • 线程ID的获取和使用,以及线程创建时可能出现的隐患和解决方法。

一致性问题探讨:

  • 当多个线程共享内存时,可能存在一致性问题,特别是在多个线程对共享变量进行读写操作时。
  • 一致性问题的根源在于修改操作的原子性不足,可能导致读取到中间状态的数据。

同步机制:

  • 互斥量(Mutex):用于保证同一时间只有一个线程访问共享资源。
  • 读写锁(RWLock):适用于读多写少的场景,提供更高的并行性。
  • 条件变量(Cond):与互斥量结合使用,用于线程间的条件等待和通知。
  • 自旋锁(Spinlock):适用于短时间锁定场景,减少线程切换开销。
  • 屏障(Barrier):用于协调多个线程的并行工作,使它们在某个点上同步。

若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途
在这里插入图片描述

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com