1227吐槽

现在是2014-12-27凌晨1点左右,又是半夜,发现睡前吐槽是个好习惯。

明天就是周六了,早上可以好好睡一觉的吧,这是我现在能想到最开心的事情了。

这周忙的要死,基本上每天一到公司,就像陀螺一直在转一样,还有一些乱七八糟的事情,但很多事情走没有预想中的顺利,唉,都不知怎么办,只能自己一个一个搞,希望自己能坚持下去。

  • 周一周二忘了干嘛了。。。
  • 周三:上午CBG-BIT技术培训,交流ksarch规划,讨论静态集群事情,讨论运维的事情
  • 周四:上午server端RD例会,下午思考明年规划,然后3个小时讨论明年规划
  • 周五:上午例会,静态文件处理,下午静态集群交流,CDN沟通,运维跟进,报警跟进

今天周五也不是那么太平,一直在跟一个客户端的问题,20:30左右的时候才搞完,然后跑去球场的时候都快20:50了,踢了差不多40分钟的球,意兴阑珊的回来,才发现肚子好饿。最讨厌的就是,小区门口基本上没有一个可以吃饭的地方,尤其是供一个人吃饭的地方,弄的我现在特讨厌周末吃饭,选择困难啊。

这两天得花个时间写自己工作上面的年度总结,其实自己心里默想了,唉,失多得少,悲大于喜,也不知作何言。

当然也有开心的事情,晚上六点多的时候,我妈电话我了,我妹生了个女儿,不过我还不知道如何称呼之:)

一个so不能copy引发的问题&思考

问题

问题的背景其实比较简单,也很常见,就是如果一个bin文件,通过dlopen的方式打开so,继续连接的时候;在程序进行升级的时候,如果bin文件还在执行,不能简单的copy,一般是需要用mv之类的方法,否则很容易出core。

这是为什么呢?

分析

首先比较cp命令和mv命令的区别,大致如下:

  • cp –> open(O_WRONLY | O_TRUNC) : 原来文件的inode不变,修改内容
  • mv –> unlink | create :删除原来文件, inode 失效

总而言之,最大的区别是inode的节点的变化。那这为什么会导致程序出core呢?

这个推敲过程比较复杂,这里就简单说明概括性的结论,而且结论还不一定对。

总结

下面分析仅仅使用于linux

  • 为什么可以被copy?
    linux下面的文件被加载到内存执行,一般就两个情况,一个是exe/bin文件,一个是so文件。理论上,或者理想的话,一个文件如果被加载到内存,且程序在被执行过程,这个时候对他的原始文件的修改,是应该不被允许的。而事实上,是怎么样的呢?

    • 对bin文件:的确系统提供了保护机制,但是系统监控的最要是以inode节点为依据的。也就是说,如果你对同一个inode节点做变更的话,是不被允许的(也就是会发生TEXT BUSY)之类的错误;但是你如果先unlink,在copy一个同样名字的bin文件过来,就是可以的(这也就是linux可以热升级bin文件的缘故)。
    • 对动态加载的so文件,系统是通过mmap映射文件加载进来的,早期的linux支持DENYWRITE的选项,后来废弃了(man手册上面说明是因为会导致dos攻击,为什么?)。因此理论上我们是可以随便把一个内容cp到一个正在运行的so上面的。
  • copy发生了什么?
    明确了so可以被copy后,让我们看为什么copy之后会发生core呢?理论上你的东西都已经跑到内存里面了,我随便改改文件应该没什么影响啊?根据深入理解LINUX内核上面对mmap的说明,大概猜测如下:

    • linux是有页高速缓存的,用于缓存文件的内容;
    • mmap做内存文件映射的时候,其实并没有开辟什么新的内存空间,而是直接指向了页高速缓存的区域;
    • 当外部程序对文件进行修改的时候,页高速缓存就会失效;下一次主程序读取的时候,就会重新从磁盘文件里面加载最小的文件内容;(这个通过程序的验证,可以通过写个mmap的程序读取会动态发生内容改变的程序);

      总的来说,通过mmap获取的内存区域,在文件发生内容变化的时候,其内存地址对于的内容也会发生改变

  • 程序为什么会core呢?

    有了上面的基础,整个原因就比较清晰了;因为一个so里面被加载之后,总是需要做地址重定向的;在copy发生后,我们只是简单的更改了内容,当没有重定向地址,这样就导致了此时在访问次共享内存的时候,很多地址都是无效的,那肯定容易出core啊

跟进

  • 页高速缓存的机制
  • linux常见命令对应的操作系统调用情况

参考资料

写在1220

好像很晚了,很困,但不想马上睡,想做点事情;

这周工作上面,好多事情,都是线上的问题,包括nginx,网络以及一系列的机器死机,一直疲于应付;项目上面的事情问题更多了,都不知道怎么办;新的项目也没有顺利启动;想想都是问题,下周还得跟着,唉。。。。

生活上面没什么好说的,基本上每天的生活的东西都可以计算出来了;但担心的事情依旧没有解决,不知道怎么办。。。今天踢球的时候,天气很冷,风很大,人也很少,只有7个人,但我们还是坚持踢了两个小时,感谢陪我一起受罪的小伙伴们。突然想想,如果现在没有踢球,我会是怎么挨过来的。。。

最近有人在问我以后的事情,我发现我真是没有所谓的思考,基本上现在想都懒得想,世界上太多事情,基本控制不了,先做好自己吧,也希望能做好自己。同时也发现,年纪越大,也终究开始承认自己的无能之处了…

锁机制概述

锁机制概述

现代体系结构上的UNIX系统 读书笔记&思考

互斥问题 & 锁

在linux内核上面,或多或少都存在所谓的临界区,用户保护不允许被多个进程同时访问的资源。因此,操作系统或者硬件体系本身需要提供一种机制,来保证这些资源是互斥的使用。

这里介绍的锁,以及其衍生的一些概念(比如信号量)等,来说明一个操作系统在实现时面临的同步问题,面对这些问题可以采取的方案,思路等。最后介绍作为一个现在的操作系统linux,其提供的内核同步支持以及用户态支持。

UP 系统面临的同步问题

我们先考虑一个简单的系统,比如在之前,整个系统都只有一个CPU,这个时候他们会有临界区的问题吗?

答案是肯定,虽然我们可以一再简化系统的功能(比如不支持内核抢占),但是至少在如下两个运行环境下的代码,是需要考虑同步问题的:

  • 和中断程序共享资源的代码:由于系统总是要开中断的(想象我们关闭时钟中断可能带来的问题),此时这些代码段,面临着和中断并发访问带来的问题

  • 处在进程上下文切换环境下的代码段:考虑一些代码段,由于底层资源轻轻未得到满足(比如网络IO)等,此时,我们一般会主动放弃CPU,让系统调度其他的进程进行处理。但我们需要保证,我们代码段所保护的资源,不能被新调度进来的其他进程破坏

抽象的,其实就两大类问题,因为任意的一个UP kernel,如果不支持内核抢占的话,他的运行环境能发生并发的环境就两个情况,一个是中断,一个是进程的切换。

在一个UP的系统,如上问题,可以用如下的方案解决:

分类 抽象问题 问题 解决方案
短期互斥 一个内核代码可能会被并发执行 非抢占内核
长期互斥 进程上下文的资源保护 进程切换时如何保证资源 对每个资源有个flag标记,只有占有flag标记的进程才能访问资源
中断互斥 中断上下文的资源保护 如何 关闭中断

再简单看下长期互斥和中断互斥实现的伪代码

  • 带有中断处理器的互斥
1
2
3
4
5
6
# splhi 可以认为是关闭中断,splx是恢复中断
s = splhi();
counter ++;
splx(s);
  • 长期互斥的伪代码实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void lock_object(char* flag)
{
while(*flag)
sleep(flag);
*flag = 1
}
void unlock_object(char* flag)
{
*flag = 0;
wakeup(flag);
}

UP到MP面临的问题

单一个UP的kernel迁移到MP的时候,上面的同步机制可以认为是全部失效。大致简单说明如下:

  • 短期互斥: 在UP的情况下,只要不支持抢占,一个资源就无法被并发访问,但是MP情况下,一个资源随时有可能被多个processor直接访问,因为是否抢占都不能解决改问题
  • 带有中断的互斥:在UP情况下,只要屏蔽当前cpu的中断,就可以保证临界区的资源被保护,同样,由于多个cpu的存在,仅仅屏蔽当前CPU的中断,也无法保护临界区的资源
  • 长期互斥: review长期互斥的实现代码,同样会发现在MP环境下的问题,比如由于while和flag=1现在不是原子的,导致有可能都进入临界区

综上,如果一个kernel要迁移MP,需要采用新的策略来解决一系列的同步问题。

自旋锁的引入

我们下面主要考虑SMP kernel的实现方案。因为,事实上,在UP迁移到MP的过程中,我们可以采用很多折衷的策略,一个最简单的迁移机制就是所谓的主从架构,就是kernel就跑着一个cpu上面,这样kernel就类似一个UP了,所需要解决的问题就是如果和其他的cpu管理运行队列等,这个就不详细说明。

在MP系统下面,需要硬件体系提供一些基本的同步支持,比如xchg等,我们将这个引申为自旋锁,可以认为是整个系统最核心,最基本的同步原语,不同硬件环境下面的自旋锁可以有不同的实现。

但是自旋锁的使用,必须十分小心,因为他一直耗着cpu,一旦出现死锁,系统就将不可用,具体如下:

  • 正常情况下,一个进程第二次尝试获取一个锁:如果不考虑递归上锁的支持的话,这个情况也会导致系统死锁;
  • 中断情况下:如果该处理器的基准代码已经拥有了锁,而同时改处理器的中断的处理也要获取这个锁,由于改cpu已经没机会释放锁了,这也会导致死锁。一个处理的方案是,在基准代码之前屏蔽相关的中断,防止一个cpu拥有自旋锁的同时,又产生中断。
  • 进程切换时,如果要睡眠,也可能出发死锁:且不论其他cpu在等待睡眠进程拥有的锁带来的性能损耗(要等很久),极端情况下也会发生死锁,因为有可能所有的cpu被调度后都在等待这个自旋锁,此时,没人任何的空闲的cpu能够把睡眠的进程拉起来,让他去释放锁,整个系统也就死锁了。因此,我们是不允许一个进程跨越一个上下文切换还能占有一个自旋锁

关于进程切换(睡眠)带来的锁的问题,一个方案是将某个进程占有的以及活的他们的顺序记录在改进程的U区(进程user data区域)。于是,上下文切换代码的能够释放正在被挂起的进程的锁。当进程在休眠之后重新执行的时候,它必须活的它之前占有的所有锁。这也可以由上下文切换代码执行。

感觉这边需要证明一个东西,就是一个序列,比如 L a,b,c UL 和 L a UL,L b,c UL 是等价的,否则为什么可以按照上面那样做??

MP的方案

有了自旋锁,我们再看看如何解决上面几个问题:

  • 短期互斥
    有了自旋锁,短期互斥,非常方便处理,只要对要保护的代码(资源)加上一个自旋锁保护即可。

  • 带有中断的互斥
    如上,会面临资源被其他cpu抢占的情况(同时也要避免死锁的情况),因为需要调整此时锁的实现,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 基准代码
s = splhi();
lock(&drive_lock);
counter ++;
unlock(&drive_lock);
splx(s);
# 中断处理代码:
lock(&drive_lock);
counter ++;
unlock(&drive_lock);
可以分析,通过关闭中断,可以保证基准代码和中断的代码不会在一个cpu上面(避免死锁);而如果中断代码和基准代码在不同的cpu上面的话,通过自旋锁来保护资源。
  • 长期互斥

如上,在一个MP系统的长期互斥的问题,主要是while和flag=1的操作并不是原子的。有了自旋锁,这个问题就能解决了,具体如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void lock_object(char* flag_ptr)
{
lock(& object_locking);
while(*flag_ptr)
sleep(flag_ptr);
*flag_ptr = 1;
unlokc(& object_locking);
}
void unlock_object(char* flag_ptr)
{
lock(& object_locking);
*flag_ptr = 1;
wakeup(flag_ptr);
unlock(& object_locking);
}

注意一点,在lock_object的时候,当进程获取自旋锁后,发现flag_ptr为1,需要sleep等待资源的释放;也就意味着我们这里是会发生一个占有自旋锁的进程,需要休眠,这个情况,如上,会让进程切换的上下文进程处理的(包括锁的释放&获取)

信号量

考虑到上面的长期互斥的支持,可以发现的两个比较大的问题:

  1. sleep/wakeup 的时候会唤醒多个等待进程,可能存在惊群效应
  2. 为了允许拥有自旋锁的进程能够睡醒,在进程进行上下文切换的时候,需要保存一些锁的信息以便恢复

因此,这里我们引入了一个新的同步原语,信号量,信号量本身是非常强大的,下面简单介绍下。

  • 定义

copy from wiki

1
2
3
4
5
6
7
计数信号量具备两种操作动作,之前称为 V(又称signal())与 P(wait())。 V操作会增加信号量 S的数值,P操作会减少它。
运作方式:
初始化,给与它一个非负数的整数值。
运行 P(wait()),信号量S的值将被减少。企图进入临界区段的进程,需要先运行 P(wait())。当信号量S减为负值时,进程会被挡住,不能继续;当信号量S不为负值时,进程可以获准进入临界区段。
运行 V(又称signal()),信号量S的值会被增加。退出离开临界区段的进程,将会运行 V(又称signal())。当信号量S不为负值时,先前被挡住的其他进程,将可获准进入临界区段。
  • 作用

由于count可以自己定义,因此我们对信号量的使用也是可以丰富的,比如:

* 互斥:当count为1的时候,就是一个mutex
* 同步:可以用来对一个事件的同步
* 资源分配: ---
  • 实现

这里简单列下对信号量的实现,可以简单的考虑如下:

  • 信号量需要维护一个队列,用户唤醒/记住等待的队列
  • 由于在MP系统下面,因此需要自旋锁
  • 尽量解决sleep/wakeup对系统带来的额外负担

简单demo如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
struct semaphore {
lock_t lock;
int count;
proc_t* head;
proc_t* tail;
};
typedef struct semaphore sema_t;
void init_sema(sema_t* sp, int cnt)
{
initlock(&sp->lock);
sp->head = sp->tail = NULL;
sp->count = cnt;
}
void p(sema_t* sp)
{
lock(&sp->lock);
sp->count --;
if(sp->count < 0) {
if(sp->head == NULL)
sp->head = u.u_procp;
else
sp->tail->p_next = u.u_procp;
u.u_procp->p_next = NULL;
sp->tail = u.u_procp;
unlock(& sp->lock);
swtch();
return;
}
unlock(& sp->lock);
}

可以看到,为了避免一个休眠的进程拥有spin lock,我们在swtch()*要先unlock,然后进行swtch();
swtch()本身会进行一个上下文切换;当进程被唤醒的时候,他会从上一次中断的地方继续执行。

可以注意的事,虽然信号量使用了自旋锁,但他用自旋锁保护的仅仅是队列,而不是大的资源。而整个保护的临界区是非常小的,可以任务不用担心进程睡眠等复杂的问题。

v的实现也类似,但有一些注意条件,这里不详述。

最后,我们在考虑用信号量来满足长期互斥的需求。其实很简单,只要让相关要保护的资源和一个信号量挂钩起来就可以了。而且此时,不需要主动sleep和wakeup,因为信号量本身默认支持此类操作。

总结

通过上面的分析,一个MP的kernel的同步机制,最重要的两个原语,一个是自旋锁,一个是信号量;
自旋锁是一切的基础,可以提供解决短期的互斥。而信号量的实现是基于自旋锁,但他和系统合成之后,支持进程挂起等更强大的支持。

上面说明的只是操作系统层面的最基本的锁机制。事实上,我们在考究真正锁实现或者锁应用的时候,会发现他们远比想象中的负责,个人认为,主要有两个原因:

  • 其一,锁和死锁的问题总是如影随形,而整个kernel的运行环境又是比较复杂的,抢占,中断,上下文切换等,对锁的使用都是要小心翼翼,比如spin lock就有N个内核的版本等,要真正理解必须对整个linux的运行机制有较深的认识;
  • 其二,在MP的系统,锁的性能直接影响着整个内核的性能,因此对锁的优化和研究可以任务是无所不用其极,比如引入了RCU,seq-LOCK等,单单一个spin lock,就有N个版本的实现和考虑。

对于一个非内核的开发者,对锁的掌握,最好做到能够了解现有linux提供的锁支持,以及用户态情况下pthread库的实现。这个也是下面会具体再讨论的。

参考