【unix高级编程系列】线程

2024-09-07 19:28
文章标签 线程 系列 编程 高级 unix

本文主要是介绍【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,人生尽是坦途
在这里插入图片描述

这篇关于【unix高级编程系列】线程的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1145939

相关文章

Java中的xxl-job调度器线程池工作机制

《Java中的xxl-job调度器线程池工作机制》xxl-job通过快慢线程池分离短时与长时任务,动态降级超时任务至慢池,结合异步触发和资源隔离机制,提升高频调度的性能与稳定性,支撑高并发场景下的可靠... 目录⚙️ 一、调度器线程池的核心设计 二、线程池的工作流程 三、线程池配置参数与优化 四、总结:线程

WinForm跨线程访问UI及UI卡死的解决方案

《WinForm跨线程访问UI及UI卡死的解决方案》在WinForm开发过程中,跨线程访问UI控件和界面卡死是常见的技术难题,由于Windows窗体应用程序的UI控件默认只能在主线程(UI线程)上操作... 目录前言正文案例1:直接线程操作(无UI访问)案例2:BeginInvoke访问UI(错误用法)案例

Linux线程之线程的创建、属性、回收、退出、取消方式

《Linux线程之线程的创建、属性、回收、退出、取消方式》文章总结了线程管理核心知识:线程号唯一、创建方式、属性设置(如分离状态与栈大小)、回收机制(join/detach)、退出方法(返回/pthr... 目录1. 线程号2. 线程的创建3. 线程属性4. 线程的回收5. 线程的退出6. 线程的取消7.

Linux下进程的CPU配置与线程绑定过程

《Linux下进程的CPU配置与线程绑定过程》本文介绍Linux系统中基于进程和线程的CPU配置方法,通过taskset命令和pthread库调整亲和力,将进程/线程绑定到特定CPU核心以优化资源分配... 目录1 基于进程的CPU配置1.1 对CPU亲和力的配置1.2 绑定进程到指定CPU核上运行2 基于

Javaee多线程之进程和线程之间的区别和联系(最新整理)

《Javaee多线程之进程和线程之间的区别和联系(最新整理)》进程是资源分配单位,线程是调度执行单位,共享资源更高效,创建线程五种方式:继承Thread、Runnable接口、匿名类、lambda,r... 目录进程和线程进程线程进程和线程的区别创建线程的五种写法继承Thread,重写run实现Runnab

SpringBoot线程池配置使用示例详解

《SpringBoot线程池配置使用示例详解》SpringBoot集成@Async注解,支持线程池参数配置(核心数、队列容量、拒绝策略等)及生命周期管理,结合监控与任务装饰器,提升异步处理效率与系统... 目录一、核心特性二、添加依赖三、参数详解四、配置线程池五、应用实践代码说明拒绝策略(Rejected

Python中你不知道的gzip高级用法分享

《Python中你不知道的gzip高级用法分享》在当今大数据时代,数据存储和传输成本已成为每个开发者必须考虑的问题,Python内置的gzip模块提供了一种简单高效的解决方案,下面小编就来和大家详细讲... 目录前言:为什么数据压缩如此重要1. gzip 模块基础介绍2. 基本压缩与解压缩操作2.1 压缩文

Java 线程安全与 volatile与单例模式问题及解决方案

《Java线程安全与volatile与单例模式问题及解决方案》文章主要讲解线程安全问题的五个成因(调度随机、变量修改、非原子操作、内存可见性、指令重排序)及解决方案,强调使用volatile关键字... 目录什么是线程安全线程安全问题的产生与解决方案线程的调度是随机的多个线程对同一个变量进行修改线程的修改操

Go语言数据库编程GORM 的基本使用详解

《Go语言数据库编程GORM的基本使用详解》GORM是Go语言流行的ORM框架,封装database/sql,支持自动迁移、关联、事务等,提供CRUD、条件查询、钩子函数、日志等功能,简化数据库操作... 目录一、安装与初始化1. 安装 GORM 及数据库驱动2. 建立数据库连接二、定义模型结构体三、自动迁

Java中的for循环高级用法

《Java中的for循环高级用法》本文系统解析Java中传统、增强型for循环、StreamAPI及并行流的实现原理与性能差异,并通过大量代码示例展示实际开发中的最佳实践,感兴趣的朋友一起看看吧... 目录前言一、基础篇:传统for循环1.1 标准语法结构1.2 典型应用场景二、进阶篇:增强型for循环2.