【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

相关文章

Spring Security 从入门到进阶系列教程

Spring Security 入门系列 《保护 Web 应用的安全》 《Spring-Security-入门(一):登录与退出》 《Spring-Security-入门(二):基于数据库验证》 《Spring-Security-入门(三):密码加密》 《Spring-Security-入门(四):自定义-Filter》 《Spring-Security-入门(五):在 Sprin

Linux 网络编程 --- 应用层

一、自定义协议和序列化反序列化 代码: 序列化反序列化实现网络版本计算器 二、HTTP协议 1、谈两个简单的预备知识 https://www.baidu.com/ --- 域名 --- 域名解析 --- IP地址 http的端口号为80端口,https的端口号为443 url为统一资源定位符。CSDNhttps://mp.csdn.net/mp_blog/creation/editor

【Python编程】Linux创建虚拟环境并配置与notebook相连接

1.创建 使用 venv 创建虚拟环境。例如,在当前目录下创建一个名为 myenv 的虚拟环境: python3 -m venv myenv 2.激活 激活虚拟环境使其成为当前终端会话的活动环境。运行: source myenv/bin/activate 3.与notebook连接 在虚拟环境中,使用 pip 安装 Jupyter 和 ipykernel: pip instal

科研绘图系列:R语言扩展物种堆积图(Extended Stacked Barplot)

介绍 R语言的扩展物种堆积图是一种数据可视化工具,它不仅展示了物种的堆积结果,还整合了不同样本分组之间的差异性分析结果。这种图形表示方法能够直观地比较不同物种在各个分组中的显著性差异,为研究者提供了一种有效的数据解读方式。 加载R包 knitr::opts_chunk$set(warning = F, message = F)library(tidyverse)library(phyl

【生成模型系列(初级)】嵌入(Embedding)方程——自然语言处理的数学灵魂【通俗理解】

【通俗理解】嵌入(Embedding)方程——自然语言处理的数学灵魂 关键词提炼 #嵌入方程 #自然语言处理 #词向量 #机器学习 #神经网络 #向量空间模型 #Siri #Google翻译 #AlexNet 第一节:嵌入方程的类比与核心概念【尽可能通俗】 嵌入方程可以被看作是自然语言处理中的“翻译机”,它将文本中的单词或短语转换成计算机能够理解的数学形式,即向量。 正如翻译机将一种语言

系统架构师考试学习笔记第三篇——架构设计高级知识(20)通信系统架构设计理论与实践

本章知识考点:         第20课时主要学习通信系统架构设计的理论和工作中的实践。根据新版考试大纲,本课时知识点会涉及案例分析题(25分),而在历年考试中,案例题对该部分内容的考查并不多,虽在综合知识选择题目中经常考查,但分值也不高。本课时内容侧重于对知识点的记忆和理解,按照以往的出题规律,通信系统架构设计基础知识点多来源于教材内的基础网络设备、网络架构和教材外最新时事热点技术。本课时知识

【编程底层思考】垃圾收集机制,GC算法,垃圾收集器类型概述

Java的垃圾收集(Garbage Collection,GC)机制是Java语言的一大特色,它负责自动管理内存的回收,释放不再使用的对象所占用的内存。以下是对Java垃圾收集机制的详细介绍: 一、垃圾收集机制概述: 对象存活判断:垃圾收集器定期检查堆内存中的对象,判断哪些对象是“垃圾”,即不再被任何引用链直接或间接引用的对象。内存回收:将判断为垃圾的对象占用的内存进行回收,以便重新使用。

flume系列之:查看flume系统日志、查看统计flume日志类型、查看flume日志

遍历指定目录下多个文件查找指定内容 服务器系统日志会记录flume相关日志 cat /var/log/messages |grep -i oom 查找系统日志中关于flume的指定日志 import osdef search_string_in_files(directory, search_string):count = 0

Go Playground 在线编程环境

For all examples in this and the next chapter, we will use Go Playground. Go Playground represents a web service that can run programs written in Go. It can be opened in a web browser using the follow

深入理解RxJava:响应式编程的现代方式

在当今的软件开发世界中,异步编程和事件驱动的架构变得越来越重要。RxJava,作为响应式编程(Reactive Programming)的一个流行库,为Java和Android开发者提供了一种强大的方式来处理异步任务和事件流。本文将深入探讨RxJava的核心概念、优势以及如何在实际项目中应用它。 文章目录 💯 什么是RxJava?💯 响应式编程的优势💯 RxJava的核心概念