早在19年5月就在某站上看到sylar的视频了,一直认为这是一个非常不错的视频,还有幸加了sylar本人的wx,由于本人一直是自学编程,基础不扎实,也没有任何人的督促,没能坚持下去,每每想起倍感惋惜。恰逢互联网寒冬,在家无事,遂提笔再续前缘。
为了能更好的看懂sylar,本套笔记会分两步走,每个系统都会分为两篇博客。
分别是【知识储备篇】和【代码分析篇】
(ps:纯粹做笔记的形式给自己记录下,欢迎大家评论,不足之处请多多赐教)
线程模块-知识储备篇
本人是自学编程,特别是服务器相关的知识都是不成体系的学习。
Sylar框架的前两篇【日志系统】和【配置系统】是比较简单的所以很容易看懂。
但是涉及到线程和协程方面就一下子懵B了,光几个专业术语就把我整不会了。
我相信有很多朋友也和我一样,在这里会被难住。
所以要看懂 Sylar 的线程模块必须要先了解以下几个专业名词:
【时间片】
【时间片轮询机制】
【上下文】
【线程】
【互斥】
【同步】
【互斥量/互斥锁】
【临界资源】
【临界区】
【条件变量】
【信号量】
【读写锁】
【自旋锁】
【CAS算法机制】
在网上找了很久,依旧很难理解。我认为是各种名词说的神乎其神导致。
【故意把简单概念复杂化】,太专业的术语往往会劝退很多人。
所以我以我自身的理解来说明一下以上各个名词的含义(如有不对的地方欢迎指出)【求关注和收藏转发】。
CPU与内存和硬盘之间的联系
先来看一张图:
在上图中我们可以看到,CPU的核心(寄存器)与内存之间隔了好几层缓存。可想而知,CPU操作内存中的数据需要时间。
好!这里得出结论:CPU操作内存没那么简单,需要时间。
CPU如何执行命令
我们知道C++是高级语言,最终会编译成汇编语言,C++中看似 整体 的一个操作,在汇编中可能是需要转换为好几条操作指令。
如果你对汇编比较感兴趣我建议你去看一下李忠老师的 《x86汇编语言:从实模式到保护模式》
这里埋下个伏笔:后期我也将做汇编这一系列,可以关注我,会及时通知。
假设有以下一段C++代码:
int main() {
int a = 1;
int b = 2;
a = a + b;
return 0 ;
}
转换为汇编后是这样:
push rbp
mov rbp,rsp
;int a = 1;
mov DWORD PTR [rbp-0x4],0x1
;int b = 2;
mov DWORD PTR [rbp-0x8],0x2
;a = a + b;(这里在C++中是一条代码,而在汇编中就变成了两条指令!!!)
mov eax,DWORD PTR [rbp-0x8]
add DWORD PTR [rbp-0x4],eax
;return 0
mov eax,0x0
pop rbp
ret
从上面的两段代码可以看出
C++中看似整体的代码:
a=a+b;
在汇编中变成了两条指令:
mov eax,DWORD PTR [rbp-0x8]
add DWORD PTR [rbp-0x4],eax
现在你应该知道:C++这类高级语言中看似整体的操作有可能变成多个汇编指令,所以操作被细分了。
我这里把不可拆分的操作叫做 原子操作,显然在C++中你并不能看出哪行代码是否是原子操作。
这里姑且把汇编中的一条指令看做 原子操作(虽然可能还能被细分,但是我们姑且这么认为)。
好!这里得出结论:像C++这样的高级语言无法直接看出对应操作是否是原子操作!
CPU时间片的概念
以下是比较官方的解释:
时间片即CPU分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。
即该进程允许运行的时间,使各个程序从表面上看是同时进行的。
如果在时间片结束时进程还在运行,则CPU将被剥夺并分配给另一个进程。
如果进程在时间片结束前阻塞或结束,则CPU当即进行切换。
而不会造成CPU资源浪费。
在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行。
但在微观上:由于只有一个CPU,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行。
以下是我的理解:
在一台只有一个处理器(CPU)的电脑上,你可以同时听音乐、玩LOL、再开一个直播...
那么CPU只有一个,怎么实现同时进行呢?
CPU会通过时间片轮转的方式:先花20ms处理音乐播放上,再花20ms处理游戏,再花20ms处理直播,
理论上依旧是某一时间段只能执行一个任务,但是由于时间太短,给人的感觉就是同时进行的。
ps:
这里并不是有序执行的:听音乐->玩LOL->直播->听音乐...
而是随机的,可能是:听音乐->玩LOL->听音乐->直播->直播->玩LOL...
好!这里得出结论:线程(任务)会因为CPU时间片轮转机制,而不断的切换,且无序!
CPU 上下文切换是什么
以下是比较官方的解释:
在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行。
也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(Program Counter,PC)。
CPU 寄存器,是 CPU 内置的容量小、但速度极快的内存。
而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
它们都是 CPU 在运行任何任务前,必须的依赖环境,因此也被叫做 CPU 上下文。
CPU 上下文切换,就是先把前一个任务的 CPU 上下文(也就是 CPU 寄存器和程序计数器)保存起来,
然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。
这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
以下是我的理解:
当CPU花20ms处理完音乐播放后,会将当前播放的状态存在一个地方,然后执行下一个20ms的游戏处理。
当游戏处理20ms后又会将当前游戏的状态存放在一个地方,然后执行下一个20ms的直播处理。
当直播处理20ms后又会回到音乐播放,那么会现将之前音乐播放的状态拿到,恢复之前播放的状态继续播放,以此往复...
ps:
这里并不是有序执行的:听音乐->玩LOL->直播->听音乐...
而是随机的,可能是:听音乐->玩LOL->听音乐->直播->直播->玩LOL...
所以我理解的上下文就是:各个任务的当前状态
切换上下文就是:将当前执行结束的任务的状态存储,将将要执行的任务的状态获取。
可想而知 上下文的切换 需要消耗时间和空间,也就是需要 消耗资源
好!这里得出结论:上下文就是保存了对应线程状态的【镜像】,切换上下文需要消耗CPU资源。
综上所述:
以下的操作会有很大的几率被时间片分割!!!
//C++代码
a = a + b;
//对应asm指令
mov eax,DWORD PTR [rbp-0x8]
add DWORD PTR [rbp-0x4],eax
也就以下命令会被分在不同的时间片中执行:
第一个时间片:
mov eax,DWORD PTR [rbp-0x8]
第二个时间片:
add DWORD PTR [rbp-0x4],eax
!!!所以如果两个线程(任务)操作同一个数据(资源),在概率学上来说必然会出现数据错乱的问题!!!
【线程】
以下是比较官方的解释:
线程是cpu执行的最小单位,包括线程ID,程序计数器,寄存器集和栈。
和其他同属于一个进程的其他线程共享操作系统资源:代码区,数据区打开文件和信号。
我的理解:
之前我所说的CPU会使用时间片轮询机制调度【任务】,其中的任务就是所谓的【线程】。
对应的方法:
//创建线程开始运行相关线程函数,运行结束则线程退出
pthread_create()
//因为exit()是用来结束进程的,所以则需要使用特定结束线程的函数
pthread_eixt()
//挂起当前线程,用于阻塞式地等待线程结束,如果线程已结束则立即返回,0=成功
pthread_join()
//发送终止信号给thread线程,成功返回0,但是成功并不意味着thread会终止
pthread_cancel()
//在不包含取消点,但是又需要取消点的地方创建一个取消点,以便在一个没有包含取消点的执行代码线程中响应取消请求.
pthread_testcancel()
//设置本线程对Cancel信号的反应
pthread_setcancelstate()
//设置取消状态 继续运行至下一个取消点再退出或者是立即执行取消动作
pthread_setcanceltype()
//设置取消状态
pthread_setcancel()
【互斥】
这是比较官方的解释:
互斥是进程(线程)之间的间接制约关系。
当一个进程(线程)进入临界区使用临界资源时,另一个进程(线程)必须等待。
只有当使用临界资源的进程(线程)退出临界区后,这个进程(线程)才会解除阻塞状态。
其中【临界区】和【临界资源】会在下面解释
我的理解:
一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源,这就叫【互斥】。
这是我脑海里的场景:
资源:int a = 1;
线程:t1,t2,...tn
读取:std::cout << a << std::endl; //当然 只要我获取就算读取 不一定要输出
写入:a++; //当然 其他改变a的方式都叫写入 不一定要 ++
互斥:当线程t1读取a或者写入a的时候,其他线程都不许对a进行读取或写入。(这就是互斥)
【同步】-【一步一步】-【One by one】
这是比较官方的解释:
同步是进程(线程)之间的直接制约关系。
是指多个相互合作的进程(线程)之间互相通信、互相等待,这种相互制约的现象称为进程(线程)的同步。
我的理解:
两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。
同步就是在互斥的基础上有顺序!
这是我脑袋里的场景:
资源:int a = 1;
线程:t1,t2,...tn
目标:先让线程t1对a进行加1->再让t2对a进行乘2->...->最后让tn对a减一。(这就是同步)
【同步】两个字,侧重点在【步】,一步一步的做 One by one。
【互斥量 - mutex】
很多人把 mutex 称为互斥锁,让我很难理解,我是这么理解的:
单独的 mutex 我称之为【互斥量】
初始化后的 mutex 我称之为【互斥锁】
互斥锁的目的:(实现互斥操作)
为了解决之前所说的,因为时间片轮询机制和高级语言非原子操作导致的数据混乱问题。
就有了互斥锁,将加锁与解锁之间的代码进行锁定,让CPU执行完加锁与解锁之间的逻辑后再进行时间片轮询。
也就是实现【互斥】操作。
互斥锁的用法:
#include <pthread.h>
// 声明一个互斥量
pthread_mutex_t mtx;
// 初始化 (这里我认为 mtx 才是真正的【互斥锁】)
int pthread_mutex_init(&mtx, NULL);
// 加锁
int pthread_mutex_lock(&mtx);
// 解锁
int pthread_mutex_unlock(&mtx);
// 销毁
int pthread_mutex_destroy(&mtx);
【临界资源】
以下是比较官方的解释:
对于某些资源来说,其在同一时间只能被一个进程所占用。
这些一次只能被一个进程所占用的资源就是所谓的临界资源。
我的理解:
就是代码中加锁与解锁之间被操作的所有公共变量(资源)。
【临界区】
以下是比较官方的解释:
进程(线程)内访问临界资源的代码被成为临界区。
我的理解:
就是处在加锁与解锁之间的那一部分操作变量的代码。
【条件变量 - condition variable】
条件变量的目的:(在互斥的前提下实现同步操作)
让CPU时间片轮询机制变得可控。
因为CPU时间片轮询并不是顺序的,而是抢占式的。
条件变量的存在可以让一个任务执行完成后指定下一个要执行的任务。
这样执行顺序就能被确定下来,保证我们的逻辑正常。
条件变量的用法:
#include <pthread.h>
#include <time.h>
// 声明一个互斥量
pthread_mutex_t mtx;
// 申明一个条件变量
pthread_cond_t cond;
timespec abstime;
abstime.tv_sec = time(NULL) + 3;
abstime.tv_nsec = 0;
// 初始化条件变量
int pthread_cond_init(&cond, NULL);
// 阻塞等待
int pthread_cond_wait(&cond, &mtx);
// 超时等待
int pthread_cond_timewait(&cond, &mtx, &abstime);
// 解除所有线程的阻塞
int pthread_cond_destroy(&cond);
// 至少唤醒一个等待该条件的线程
int pthread_cond_signal(&cond);
// 唤醒等待该条件的所有线程
int pthread_cond_broadcast(&cond);
【信号量 - semaphore】
以下是比较官方的解释:
信号量是用来解决线程同步和互斥的通用工具, 和互斥量类似。
信号量也可以用作自于资源互斥访问, 但信号量没有所有者的概念,在应用上比互斥量更广泛,信号量比较简单, 不能解决优先级反转问题。
但信号量是一种轻量级的对象,比互斥量小巧,灵活,因此在很多对互斥要求不严格的的系统中,经常使用信号量来管理互斥资源。
如果定义的信号量表示一种资源,则它是用来同步的,如果信号量定义成一把锁,则它是用来保护的。
我的理解:
信号量可以实现【同步】。
信号量的用法:
#include <pthread.h>
// 声明一个信号量
sem_t sem;
// 初始化信号量
int sem_init(&sem, 0, 0);
// 信号量P操作(减 1)
int sem_wait(&sem);
// 以非阻塞的方式来对信号量进行减1操作
int sem_trywait(&sem);
// 信号量V操作(加 1)
int sem_post(&sem);
// 获取信号量的值
int value;
int sem_getvalue(&sem, &value);
// 销毁信号量
int sem_destroy(&sem);
【读写锁 - read-write lock】
以下是比官方的解释:
读写锁是一对互斥锁,分为读锁和写锁。
读锁和写锁互斥,让一个线程在进行读操作时,不允许其他线程的写操作,但是不影响其他线程的读操作。
当一个线程在进行写操作时,不允许任何线程进行读操作或者写操作。
我的理解:
读写锁可以用在【读多写少】的情况。
读写锁的用法:
#include <pthread.h>
// 定义读写锁
pthread_rwlock_t rwlock;
// 初始化
pthread_rwlock_init(&rwlock, NULL);
// 加读锁
int pthread_rwlock_rdlock(&rwlock);
// 加写锁
int pthread_rwlock_wrlock(&rwlock);
// 释放锁
int pthread_rwlock_unlock(&rwlock);
【自旋锁 - spinlock】
以下是比较官方的解释:
一种基于忙等待的锁机制,它是一种轻量级的锁实现方式。
与传统的阻塞锁不同,自旋锁在获取锁时不会主动阻塞线程,而是通过循环不断地尝试获取锁,直到成功获取为止。
在多核处理器的环境下,自旋锁通常会使用底层的原子操作(如CAS)来实现。
当一个线程尝试获取自旋锁时,如果发现锁已经被其他线程持有,它会在循环中不断地检查锁的状态,而不是被挂起或阻塞。
这样做的目的是为了避免线程被切换到其他任务上,从而减少上下文切换的开销。
自旋锁适用于以下情况:
1.锁的保持时间很短:如果临界区的代码执行时间很短,使用自旋锁可以避免线程切换的开销,从而提高性能。
2.并发冲突较少:自旋锁适用于并发冲突较少的情况。如果临界区的竞争激烈,自旋锁可能会导致大量的线程空转,浪费CPU资源。
3.不可阻塞:自旋锁要求获取锁的操作是非阻塞的,即不会引起线程的挂起或阻塞。
如果获取锁的操作可能会引起线程的阻塞,使用自旋锁就不合适,应该选择其他类型的锁。
试用场景和副作用:
需要注意的是,自旋锁在等待期间会占用CPU资源,因此适合于临界区代码执行时间短、并发冲突较少的情况。
如果临界区的执行时间较长,或者并发冲突较多,使用自旋锁可能会导致性能下降。
此外,自旋锁还可能引发活跃性问题,如死锁或饥饿,因此在使用自旋锁时需要仔细考虑并发场景和锁的使用方式。
我的理解:
0.自旋锁的用法和互斥锁几乎一样,只不过机制不同。
1.【互斥锁】会将等待获取锁的线程从运行中状态转换为阻塞状态,这样可以避免线程占用过多时间片。
而自旋锁则会一直循环判断锁是否可用,这种方式适用于锁被持有的时间较短的情况。
2.【自旋锁】可以使用CAS、原子操作等非阻塞的方式,减少线程进入内核态的开销,提高效率;
在单核CPU上,自旋锁可能比互斥锁更快,但在线程并发度较高的情况下,自旋锁会浪费更多的CPU资源。
自旋锁的用法:
#include <pthread.h>
// 声明一个互斥量
pthread_spinlock_t spinlock;
// 初始化
int pthread_spin_init(&spinlock, NULL);
// 加锁
int pthread_spin_lock(&spinlock);
// 加锁,申请不到直接返回;
int pthread_spin_trylock(&spinlock);
// 解锁
int pthread_spin_unlock(&spinlock);
// 销毁
int pthread_spin_destroy(&spinlock);
【CAS算法机制】(compare and swap)比较和交换
什么是CAS自旋锁:
属于乐观锁的一种。
通俗点说,当我们想修改一个值时,我们会先将这个值和原先的值进行比较,如果发现和原先的值一样,那么我们再进行修改。
CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。
更新一个变量时,只有当预期值A和内存地址V中的实际值相同时,才会将内存地址对应的值修改为B。
如果发现不一致,则会重新进行尝试,这个尝试的过程被称为自旋。
CAS的缺点:
1.cpu开销大:
自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。
在高并发的情况下,如果多个线程同时更新一个值不成功,就会一直自旋,增大cpu压力。
2.不能保证代码块的原子性:
cas保证的是一个变量的原子性操作,如果要保证多个变量或者代码块的原子性,就要使用synchronized。
3.ABA问题(加版本号):
对于某一个数据,有一个线程将它操作后又恢复原状,而我们无法分辨。
atomic_flag成员函数:
clear():将标志设置为false。
test_and_set():设置标志为true,并返回之前的值。
test_and_set_explicit():与test_and_set()类似,但可以指定一个内存顺序参数。
test_and_reset():设置标志为false,并返回之前的值。
test_and_reset_explicit():与test_and_reset()类似,但可以指定一个内存顺序参数。
wait():等待标志变为false。
notify_one():唤醒等待该标志的一个线程。
notify_all():唤醒等待该标志的所有线程。
atomic_flag用法(实现自旋锁):
#include <atomic>
#include <thread>
#include <iostream>
std::atomic_flag lock = ATOMIC_FLAG_INIT;
void critical_section() {
while (lock.test_and_set()) {
// 如果锁被其他线程持有,则等待(自旋)
}
// 临界区代码
std::cout << "Thread " << std::this_thread::get_id() << " is in the critical section.\n";
// 释放锁
lock.clear();
}
int main() {
std::thread t1(critical_section);
std::thread t2(critical_section);
t1.join();
t2.join();
return 0;
}
atomic_flag注意事项:
1.std::atomic_flag 的初始值是不确定的,除非你使用 ATOMIC_FLAG_INIT 进行初始化。
2.由于 std::atomic_flag 的功能非常简单,它可能不适合所有场景。对于更复杂的原子操作,你可能需要使用 std::atomic 模板类。
3.在使用 std::atomic_flag 实现自旋锁时,需要注意忙等待(自旋)可能会导致 CPU 资源浪费。
在需要等待较长时间的情况下,使用其他类型的锁(如互斥锁)可能更为合适。
本章是对学习Sylar线程模块的知识储备,下一章将会对Sylar线程模块进行代码分析。
如果大家觉得有帮助可以看下【本专栏】下的其他文章。