【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

相关文章

shell编程之函数与数组的使用详解

《shell编程之函数与数组的使用详解》:本文主要介绍shell编程之函数与数组的使用,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录shell函数函数的用法俩个数求和系统资源监控并报警函数函数变量的作用范围函数的参数递归函数shell数组获取数组的长度读取某下的

MySQL高级查询之JOIN、子查询、窗口函数实际案例

《MySQL高级查询之JOIN、子查询、窗口函数实际案例》:本文主要介绍MySQL高级查询之JOIN、子查询、窗口函数实际案例的相关资料,JOIN用于多表关联查询,子查询用于数据筛选和过滤,窗口函... 目录前言1. JOIN(连接查询)1.1 内连接(INNER JOIN)1.2 左连接(LEFT JOI

前端高级CSS用法示例详解

《前端高级CSS用法示例详解》在前端开发中,CSS(层叠样式表)不仅是用来控制网页的外观和布局,更是实现复杂交互和动态效果的关键技术之一,随着前端技术的不断发展,CSS的用法也日益丰富和高级,本文将深... 前端高级css用法在前端开发中,CSS(层叠样式表)不仅是用来控制网页的外观和布局,更是实现复杂交

揭秘Python Socket网络编程的7种硬核用法

《揭秘PythonSocket网络编程的7种硬核用法》Socket不仅能做聊天室,还能干一大堆硬核操作,这篇文章就带大家看看Python网络编程的7种超实用玩法,感兴趣的小伙伴可以跟随小编一起... 目录1.端口扫描器:探测开放端口2.简易 HTTP 服务器:10 秒搭个网页3.局域网游戏:多人联机对战4.

Java并发编程必备之Synchronized关键字深入解析

《Java并发编程必备之Synchronized关键字深入解析》本文我们深入探索了Java中的Synchronized关键字,包括其互斥性和可重入性的特性,文章详细介绍了Synchronized的三种... 目录一、前言二、Synchronized关键字2.1 Synchronized的特性1. 互斥2.

Spring Boot3虚拟线程的使用步骤详解

《SpringBoot3虚拟线程的使用步骤详解》虚拟线程是Java19中引入的一个新特性,旨在通过简化线程管理来提升应用程序的并发性能,:本文主要介绍SpringBoot3虚拟线程的使用步骤,... 目录问题根源分析解决方案验证验证实验实验1:未启用keep-alive实验2:启用keep-alive扩展建

Python异步编程中asyncio.gather的并发控制详解

《Python异步编程中asyncio.gather的并发控制详解》在Python异步编程生态中,asyncio.gather是并发任务调度的核心工具,本文将通过实际场景和代码示例,展示如何结合信号量... 目录一、asyncio.gather的原始行为解析二、信号量控制法:给并发装上"节流阀"三、进阶控制

Java终止正在运行的线程的三种方法

《Java终止正在运行的线程的三种方法》停止一个线程意味着在任务处理完任务之前停掉正在做的操作,也就是放弃当前的操作,停止一个线程可以用Thread.stop()方法,但最好不要用它,本文给大家介绍了... 目录前言1. 停止不了的线程2. 判断线程是否停止状态3. 能停止的线程–异常法4. 在沉睡中停止5

kotlin中的行为组件及高级用法

《kotlin中的行为组件及高级用法》Jetpack中的四大行为组件:WorkManager、DataBinding、Coroutines和Lifecycle,分别解决了后台任务调度、数据驱动UI、异... 目录WorkManager工作原理最佳实践Data Binding工作原理进阶技巧Coroutine

Java捕获ThreadPoolExecutor内部线程异常的四种方法

《Java捕获ThreadPoolExecutor内部线程异常的四种方法》这篇文章主要为大家详细介绍了Java捕获ThreadPoolExecutor内部线程异常的四种方法,文中的示例代码讲解详细,感... 目录方案 1方案 2方案 3方案 4结论方案 1使用 execute + try-catch 记录