Multithreading and Thread Synchrony
多线程与线程同步
来自B站:爱编程的大丙
代码仓库:https://github.com/Rain0832/CSDIY_Demo/tree/main/Threads
我将其定义为操作系统导论与实践。
操作系统最重要的理解就是进程/线程,锁机制等。
1. 多线程特点
线程是轻量级的进程(LWP:light weight process),在Linux环境下线程的本质仍是进程。
进程是资源分配的最小单位,线程是操作系统调度执行的最小单位。
线程不是越多越好。而是够用就好
文件IO操作:文件IO对CPU是使用率不高, 因此可以分时复用CPU时间片, 线程的个数 = 2 * CPU核心数 (效率最高)
处理复杂的算法(主要是CPU进行运算, 压力大),线程的个数 = CPU的核心数 (效率最高)
2. 线程的创建、退出、回收方法
线程库 - C语言:
#include <pthread.h>
2.1. 线程函数
线程ID函数:pthread_t pthread_self(void);
创建函数:int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
2.2. 创建线程
2.3. 线程退出
退出函数:void pthread_exit(void *retval);
2.4. 线程回收
回收函数:int pthread_join(pthread_t thread, void **retval);
这个函数是一个阻塞函数,如果还有子线程在运行,调用该函数就会阻塞,子线程退出函数解除阻塞进行资源的回收,函数被调用一次,只能回收一个子线程,如果有多个子线程则需要循环进行回收。
3. 其他线程函数
3.1. 线程分离
分离函数:int pthread_detach(pthread_t thread);
参数是子线程的线程ID, 主线程会和这个子线程分离
3.2. 线程取消
取消函数:int pthread_cancel(pthread_t thread);
3.3. 线程ID比较
比较函数:int pthread_equal(pthread_t t1, pthread_t t2);
比较两个线程的ID是否相同。
3.4. C++线程类
以上的函数C++中都支持。另外C++11也提供了线程类支持。
C++11中提供的线程类叫做std::thread。
3.4.1. 构造函数
3.4.2. 公共成员函数
获取线程ID:
get_id(): std::thread::id get_id() const noexcept;
连接线程:
join(): void join();
线程分离:
detach(): void detach();
判断主线程与子线程是否关联:
joinable(): bool joinable() const noexcept;
线程的复制、粘贴(类似):
线程中的资源是不能被复制的,因此通过=操作符进行赋值操作最终并不会得到两个完全相同的对象。
静态函数
static unsigned hardware_concurrency() noexcept;
4. 线程同步处理思路
假设有4个线程A、B、C、D,当前一个线程A对内存中的共享资源进行访问的时候,其他线程B, C, D都不可以对这块内存进行操作,直到线程A对这块内存访问完毕为止,B,C,D中的一个才能访问这块内存,剩余的两个需要继续阻塞等待,以此类推,直至所有的线程都对这块内存操作完毕。 线程对内存的这种访问方式就称之为线程同步,通过对概念的介绍,我们可以了解到所谓的同步并不是多个线程同时对内存进行访问,而是按照先后顺序依次进行的。
通过锁机制可以让程序能够按照预期执行,而不会因为线程对共享资源的访问沟通出错使得程序出现未知错误。
线程同步的大致处理思路是这样的:
在临界区代码的上边,添加加锁函数,对临界区加锁。
哪个线程调用这句代码,就会把这把锁锁上,其他线程就只能阻塞在锁上了。
在临界区代码的下边,添加解锁函数,对临界区解锁。
出临界区的线程会将锁定的那把锁打开,其他抢到锁的线程就可以进入到临界区了。
通过锁机制能保证临界区代码最多只能同时有一个线程访问,这样并行访问就变为串行访问了。
5. 互斥锁
5.1. 互斥锁变量与函数
互斥锁变量:pthread_mutex_t mutex;
初始化互斥锁:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
释放互斥锁资源:int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥锁锁定:int pthread_mutex_lock(pthread_mutex_t *mutex);
尝试加锁函数:int pthread_mutex_trylock(pthread_mutex_t *mutex);
相当于在加锁前做一次判断。
互斥锁解锁:int pthread_mutex_unlock(pthread_mutex_t *mutex);
5.2. 互斥锁使用
6. 线程死锁
6.1. 常见场景
加锁后忘记解锁
重复加锁,造成死锁
有多个共享资源时,随意加锁,导致互相被阻塞
6.2. 如何避免死锁
避免多次锁定,多检查
对共享资源访问完毕之后,一定要解锁,或者在加锁的时候使用
trylock如果程序中多把锁,可以控制对锁的访问顺序,也可以在对其他互斥锁进行加锁之前,先释放当前线程拥有的互斥锁
项目中可以引入一些专门用于死锁检测的模块
7. 读写锁
其实是一把锁,只是有两种使用方式,基本类似互斥锁。
7.1. 读写锁变量与函数函数
读写锁变量:pthread_rwlock_t rwlock;
初始化读写锁:int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
释放读写锁:int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
锁定读操作:int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
尝试锁定读操作:int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
锁定写操作:int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
尝试锁定写操作:int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);\
解锁操作:int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);
7.2. 读写锁使用
8. 条件变量
条件变量的主要作用不是处理线程同步, 而是进行线程的阻塞。
8.1. 条件变量与函数
条件变量:pthread_cond_t cond;
初始化:int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
销毁释放资源:int pthread_cond_destroy(pthread_cond_t *cond);
线程阻塞函数:int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
其作用有:
阻塞线程
阻塞线程之前把线程的互斥锁打开
在线程解除阻塞之后,自动给线程加上互斥锁
8.2. 生产者与消费者模型
生产者线程 -> 若干个
生产商品或者任务放入到任务队列中
任务队列满了就阻塞, 不满的时候就工作
通过一个生产者的条件变量控制生产者线程阻塞和非阻塞
消费者线程 -> 若干个
读任务队列, 将任务或者数据取出
任务队列中有数据就消费,没有数据就阻塞
通过一个消费者的条件变量控制消费者线程阻塞和非阻塞
队列 -> 存储任务/数据,对应一块内存,为了读写访问可以通过一个数据结构维护这块内存
可以是数组、链表,也可以使用stl容器:queue / stack / list / vector
8.3. 条件变量使用
9. 信号量
信号量用在多线程多任务同步的,一个线程完成了某一个动作就通过信号量告诉别的线程,别的线程再进行某些动作。
9.1. 信号量变量与函数
信号量变量:sem_t sem;
初始化信号量/信号灯:int sem_init(sem_t *sem, int pshared, unsigned int value);
资源释放:int sem_destroy(sem_t *sem);
线程销毁之后调用这个函数即可
消耗资源:int sem_wait(sem_t *sem);
sem中资源消耗1个,当资源 == 0,线程会被阻塞相当于一个计数器
尝试消耗资源:int sem_trywait(sem_t *sem);
增加资源:int sem_post(sem_t *sem);
sem中资源增加1个
查看资源:int sem_getvalue(sem_t *sem, int *sval);
查看sem中当前值
9.2. 信号量使用
10. 总结
操作系统的学习重点就在于理解系统的线程如何操作,线程又引申出了多线程与线程同步的概念,也就是这次副本的主要内容。另外也涉及到了系统内存的知识,这次的代码为C语言实现,80%的代码是手搓的。另外借此机会重新复用了Ubuntu系统进行编程。这次全程使用vim编辑器进行编程。在一次次的问kimi的过程中,一次次的又忘记,借助AI让自己的编程能力快速成长。
这次只能算是浅尝辄止,甚至不能算作操作系统的导论课,只能算作线程的导论课,关于操作系统和线程都还有很多知识和实践经验,单靠这几百行代码远远不够,只能是了解个大概,但这也够了。
Last updated