1.const, inline, volatile 关键字
1.static关键字解析
ANSI标准规定了C具有32个关键字,其中绝大多数并无特别之处,除了涉及到存储类型的几个关键字,而我们的static关键字便是属于存储类型声明的关键字一类:
1. auto: 声明该变量标识符是存放在栈上的(局部变量的默认修饰符),编译器自动完成,现今不需要手动声明,故而该修饰符几乎不用;
2. register: 声明寄存器变量,我们知道变量存放在寄存器中将会非常有利于关于该变量的读写加速,我们在编译原理的代码优化知道,寄存器使用优化是目前编译器的核心功能,考虑大现今编译器的强大功能,这个修饰符也几乎不用;
3. extern: 声明该变量或函数的定义在其他文件处,全局变量和函数都是默认extern的,故而这也是声明全局变量的初始化全局变量可以不在一起的原因;
4. static: 更改存储位置和访问权限
(1) static修饰局部变量,是为了改变局部变量的存储段,从栈的临时区变换成.data段,延长了声明周期;
(2) static修饰全局变量,是为了限制全局变量的访问范围,让此全局变量变成本文件内可访问;
(3) static修饰函数,也是为了限制函数的访问范围,static函数本文件内可见;
(4) static修饰类成员变量,该变量归类所有,全体实例对象共享一份,单例模式用这个特性实现;
(5) static修饰类成员函数,该函数只能用来配置操作static类成员变量。并且只有当程序结束时才从内存消失,因此只能用来操作静态成员
2.inline关键字解析
其实关于inline修饰符的使用往往要和宏联系在一起,比如如下的表达式的宏定义
#define ExpressionName(var1, var2) (var1+var2)*(var1-var2);
优点:这种宏定义形式的代码替换工作是在预编译阶段实现,没有参数压栈,代码生成等一系列操作,效率高,这是这种宏定义被使用的主要原因;
缺点:但是这种宏定义形式只是做了预处理器符号表中的简单替换,不能主动地进行参数有效性检测 ,也不能享受C++编译器严格的类型检查和提示WARNING的强大功能,并且宏表达式的返回值也不能被强制转换成可转换的合适的类型,正是基于这些考虑,推出inline修饰的内联函数,关于inline修饰符的具体使用,C99标准和GCC编译器额外扩展的定义存在一定区别,
inline修饰的内联函数是在编译阶段,同文件内的调用点处不使用call process这种过程调用指令,而是直接将内联函数的汇编码直接填写在同文件内的调用处;
inline定义的类的内联函数,函数的代码被放入符号表中,在使用时是直接进行替换的(像宏一样展开),没有了调用的开销,效率高;
inline可以作为某个类的成员函数修饰符,可以在函数体内部使用所在类的保护成员及私有成员。
总结来看:宏表达式的替换是字符串替换,发生在词法分析和预编译阶段;inline内联函数的替换是汇编代码级别的填充,发生在编译阶段。
3. volatile关键字解析
前面说到过编译器优化,有时会进行想当然的优化,比如会充分利用变量当前在寄存器中的缓存,但是因为编译器并无进程交互的概念,所以这种盲目的缓存读取优化可能会导致数据更新不同步的情况,所以用volatile关键字来修饰一些进程间或多线程共享信息变量,声明对该变量的每次读取都需要从内存中提取最新的数据。一般用在以下几个地方:
a、并行设备的硬件寄存器(如:状态寄存器);
b、一个中断服务子程序中会访问到的非自动变量(Non-automatic variables) ;
c、多线程应用中被几个任务共享的变量 。
总结来看:正如volatile的字面意思–“易挥发,不稳定的”,volatile修饰符使用的变量确实处于实时多变的状态。
4. const关键字解析
现在来看看最复杂的也是迷惑性最强的const修饰符,const既可以修饰变量也可以修饰函数,先来看看修饰变量的情形:
1、以const修饰的常量值,具有不可变性,避免数字的直接使用,使用具有可读性的名称(const int MAX_SUM = 100;
2、C++编译器不为普通const常量分配空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有存储和读内存的操作,使得它效率更高;
3、 const修饰符是左结合的,故而考虑对于如下两种情况的分析:
int const *A; const绑定在int上,说明是修饰的int数值, 限定*A指向的内容不可变,指针A本身可变;
int * const A; const绑定在int* 指针上,限定指针A本身不可变,指针指向的内容可变
关于用于函数修饰的情形,可参考这篇文章。const修饰函数主要有三个方面可以修饰:
1. 形参修饰
(a) 值传递:因为是在栈上创建临时对象,过后即删,故而无需const保护,即不存在void Func(const int source);
(b) 指针传递: void Func(const char* source)便是规定函数内部不能对source指针指向的内容进行任何修改;
(c) 引用(别名)传递:如果传输对象size较大,创建临时对象不划算,采用引用传递,但是也面临着和指针传递一样的内容修改风险,void Func(const ArrayList &source)便是保证函数内部不能对source进行更改。
2. 返回值修饰:返回值的传递方式也是分为三种的
(a) 值传递返回值::函数内部会在栈上建立一个临时对象用来传递返回值,过后即删,同样无需const保护;
(b) 指针传递返回值:以“指针传递”方式的函数返回值加const修饰,那么函数返回值(即指针)的内容不能被修改,该返回值只能被赋给加const修饰的同类型指针,否则何来const保护作用的传承?
const char * GetString(void);
//如下语句将出现编译错误:
char* str = GetString();
//正确的用法是
const char * str = GetString();
(c) 引用(别名)传递返回值:函数返回值采用“引用传递”的场合并不多,因为这是一直在同一个数据对象进行操作,一个数据对象如果存在较多的别名,并且还是不可控的,这是很可怕的,故而这种方式一般只出现在类的赋值函数中,目的是为了实现链式表达。
3. 类成员函数功能声明
使用在C++的类函数修饰场景,声明在函数中不改变类成员,如果在函数中存在更改类成员的操作,则编译器编译时会主动指出错误,所以这种用法是有点借助编译器自我约束自我提醒的意思,可提高程序的健壮性。借用
classStack
{
public:
void Push(int elem);
int Pop(void);
int GetCount(void) const; // const 成员函数,
/*
**其实const 修饰的是this*对象指针,故而本质上和const修饰符的
**this是隐含参数,const没法直接修饰它,就加在函数的后面了。const修饰*this是本质,
**至于说“表示该成员函数不会修改类的数据。否则会编译报错”之类的说法只是一个现象,
**根源就是因为*this是const类型的
*/
private:
int m_num;
int m_data[10];
};
int Stack::GetCount(void)const
{
m_num++; // 编译错误,在const成员函数中修改类成员m_num
Pop(); // 编译错误,在const成员函数中调用非const成员函数,
/*
**非const函数的行为无法保证不修改类成员的,故而const成员函数不能调用非const成员函数
**同样的道理,如果声明 const classStack demo;即声明该类的const对象,则该对象是不能
**调用它的非const成员函数的,因为非const成员函数会修改类成员数据,这就不是const对象了
**但是const对象持有的指针变量指向的内容是可以修改的,因为const对象只需要保证指针不变就可以
**指针指向的数据变不变并不直接归属于const对象的const性质
*/
return m_num;
}
关于const成员函数的其他几点说明:
const成员函数不可以修改类的任何成员数据(除了下面的特例), 编译器在编译阶段以该函数是否修改成员数据为依据,进行合法性判断;
加上mutable修饰符的数据成员,对于任何情况下通过任何手段都可修改,自然此时的const成员函数内部是可以mutable变量的。
---------------------
作者:墨篙和小奶猫
来源:优快云
原文:https://blog.youkuaiyun.com/roger_ranger/article/details/78864417
版权声明:本文为博主原创文章,转载请附上博文链接!
2.Linux进程间通信的几种方式
inux进程间基本的通信方式主要有:管道(pipe)(包括匿名管道和命名管道)、信号(signal)、消息队列(queue)、共享内存、信号量和套接字。
下面逐渐介绍这几种方式:
1.管道:管道的实质是一个内核缓冲区,管道的作用正如其名,需要通信的两个进程在管道的两端,进程利用管道传递信息。管道对于管道两端的进程而言,就是一个文件,但是这个文件比较特殊,它不属于文件系统并且只存在于内存中。
管道依据是否有名字分为匿名管道和命名管道(有名管道),这两种管道有一定的区别。
匿名管道有几个重要的限制:
- 管道是半双工的,数据只能在一个方向上流动,A进程传给B进程,不能反向传递
- 管道只能用于父子进程或兄弟进程之间的通信,即具有亲缘关系的进程。
命名管道允许没有亲缘关系的进程进行通信。命名管道不同于匿名管道之处在于它提供了一个路径名与之关联,这样一个进程即使与创建有名管道的进程不存在亲缘关系,只要可以访问该路径,就能通过有名管道互相通信。
2.信号:信号是软件层次上对中断机制的一种模拟,是一种异步通信方式,进程不必通过任何操作来等待信号的到达。信号可以在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件。
信号来源:
信号事件的发生有两个来源:硬件来源,比如我们按下了键盘或者其它硬件故障;软件来源,最常用发送信号的系统函数是kill, raise, alarm和setitimer以及sigqueue函数,软件来源还包括一些非法运算等操作。
进程对信号的响应:
进程可以通过三种方式来响应信号:(1)忽略信号,即对信号不做任何处理,但是有两个信号是不能忽略的:SIGKLL和SIGSTOP;(2)捕捉信号,定义信号处理函数,当信号发生时,执行相应的处理函数;(3)执行缺省操作,Linux对每种信号都规定了默认操作。
3.消息队列:消息队列是消息的链表,具有特定的格式,存放在内存中并由消息队列标识符标识,并且允许一个或多个进程向它写入与读取消息
4.共享内存:使得多个进程可以可以直接读写同一块内存空间,是针对其他通信机制运行效率较低而设计的。
为了在多个进程间交换信息,内核专门留出了一块内存区,可以由需要访问的进程将其映射到自己的私有地址空间。进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。
需要注意的是:共享内存并未提供同步机制,在一个进程结束对共享内存的写操作之前,并无自动机制可以阻止另二个进程开始对它进行读取。所以,我们通常需要用其他的机制来同步对共享内存的访问。
5.信号量:信号量实质上就是一个标识可用资源数量的计数器,它的值总是非负整数。而只有0和1两种取值的信号量叫做二进制信号量(或二值信号量),可用用来标识某个资源是否可用。
6.套接字:套接字是更为基础的进程间通信机制,与其他方式不同的是,套接字可用于不同机器之间的进程间通信。
有两种类型的套接字:基于文件的和面向网络的。
(1).Unix套接字是基于文件的,并且拥有一个“家族名字”--AF_UNIX,它代表地址家族(address family):UNIX。
(2).第二类型的套接字是基于网络的,它也有自己的家族名字--AF_INET,代表地址家族(address family):INTERNET
不管采用哪种地址家族,都有两种不同的套接字连接:面向连接的和无连接的。
(1)面向连接的套接字(SOCK_STREAM):进行通信前必须建立一个连接,面向连接的通信提供序列化的、可靠地和不重复的数据交付,而没有记录边界。
这意味着每条信息可以被拆分成多个片段,并且每个片段都能确保到达目的地,然后在目的地将信息拼接起来。
实现这种连接类型的主要协议是传输控制协议(TCP)。
(2)无连接的套接字(SOCK_DGRAM):在通信开始之前并不需要建立连接,在数据传输过程中并无法保证它的顺序性、可靠性或重复性。
然而,数据报确实保存了记录边界,这就意味着消息是以整体发送的,而并非首先分成多个片段。
由于面向连接的套接字所提供的保证,因此它们的设置以及对虚拟电路连接的维护需要大量的开销。然而,数据报不需要这些开销,即它的成本更加“低廉”
实现这种连接类型的主要协议是用户数据报协议(UDP)。
3.c++父子类的构造、析构过程
对象并不是突然建立起来的,创建对象必须时必须同时创建父类以及包含于其中的对象。C++遵循如下的创建顺序:
(1)如果某个类具体基类,执行基类的默认构造函数。
(2)类的非静态数据成员,按照声明的顺序创建。静态数据成员在需要在函数外自己定义可以不初始化,属于所有实例共有,生命周期为整个程序
(3)执行该类的构造函数。
即构造类时,会先构造其父类,然后创建类成员,最后调用本身的构造函数。
接下来看析构的顺序:
(1)调用类的析构函数。
(2)销毁数据成员,与创建的顺序相反。
(3)如果有父类,调用父类的析构函数。
4.什么是大端模式?
1、大端模式
所谓的大端模式,是指数据的高字节,保存在内存的低地址中,而数据的低字节,保存在内存的高地址中,这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加,而数据从高位往低位放;
记忆方法: 地址的增长顺序与值的增长顺序相反.
2、小端模式
所谓的小端模式,是指数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中,这种存储模式将地址的高低和数据位权有效地结合起来,高地址部分权值高,低地址部分权值低,和我们的逻辑方法一致。
记忆方法: 地址的增长顺序与值的增长顺序相同.
5.内核同步有哪些方式?
1、什么是互斥与同步?(通俗理解)
- 互斥与同步机制是计算机系统中,用于控制进程对某些特定资源的访问的机制。
- 同步是指用于实现控制多个进程按照一定的规则或顺序访问某些系统资源的机制。
- 互斥是指用于实现控制某些系统资源在任意时刻只能允许一个进程访问的机制。互斥是同步机制中的一种特殊情况。
- 同步机制是linux操作系统可以高效稳定运行的重要机制。
2、Linux为什么需要同步机制?
在操作系统引入了进程概念,进程成为调度实体后,系统就具备了并发执行多个进程的能力,但也导致了系统中各个进程之间的资源竞争和共享。另外,由于中断、异常机制的引入,以及内核态抢占都导致了这些内核执行路径(进程)以交错的方式运行。对于这些交错路径执行的内核路径,如不采取必要的同步措施,将会对一些关键数据结构进行交错访问和修改,从而导致这些数据结构状态的不一致,进而导致系统崩溃。因此,为了确保系统高效稳定有序地运行,linux必须要采用同步机制。
3、Linux内核提供了哪些同步机制?
在学习linux内核同步机制之前,先要了解以下预备知识:(临界区与并发源)
在linux系统中,我们把对共享的资源进行访问的代码片段称为临界区。
把导致出现多个进程对同一共享资源进行访问的原因称为并发源。
Linux系统下并发的主要来源有:
中断处理:例如,当进程在访问某个临界资源的时候发生了中断,随后进入中断处理程序,如果在中断处理程序中,也访问了该临界资源。虽然不是严格意义上的并发,但是也会造成了对该资源的竞态。
内核态抢占:例如,当进程在访问某个临界资源的时候发生内核态抢占,随后进入了高优先级的进程,如果该进程也访问了同一临界资源,那么就会造成进程与进程之间的并发。
多处理器的并发:多处理器系统上的进程与进程之间是严格意义上的并发,每个处理器都可以独自调度运行一个进程,在同一时刻有多个进程在同时运行 。
如前所述可知:采用同步机制的目的就是避免多个进程并发访问同一临界资源。
Linux内核同步机制:
(1)禁用中断 (单处理器不可抢占系统)
由前面可以知道,对于单处理器不可抢占系统来说,系统并发源主要是中断处理。因此在进行临界资源访问时,进行禁用/使能中断即可以达到消除异步并发源的目的。Linux系统中提供了两个宏local_irq_enable与 local_irq_disable来使能和禁用中断。在linux系统中,使用这两个宏来开关中断的方式进行保护时,要确保处于两者之间的代码执行时间不能太长,否则将影响到系统的性能。(不能及时响应外部中断)
(2)自旋锁
应用背景:自旋锁的最初设计目的是在多处理器系统中提供对共享数据的保护。
自旋锁的设计思想:在多处理器之间设置一个全局变量V,表示锁。并定义当V=1时为锁定状态,V=0时为解锁状态。自旋锁同步机制是针对多处理器设计的,属于忙等机制。自旋锁机制只允许唯一的一个执行路径持有自旋锁。如果处理器A上的代码要进入临界区,就先读取V的值。如果V!=0说明是锁定状态,表明有其他处理器的代码正在对共享数据进行访问,那么此时处理器A进入忙等状态(自旋);如果V=0,表明当前没有其他处理器上的代码进入临界区,此时处理器A可以访问该临界资源。然后把V设置为1,再进入临界区,访问完毕后离开临界区时将V设置为0。
注意:必须要确保处理器A“读取V的值与更新V”这一操作是一个原子操作。所谓的原子操作是指,一旦开始执行,就不可中断直至执行结束。
自旋锁的分类:
2.1、普通自旋锁
普通自旋锁由数据结构spinlock_t来表示,该数据结构在文件src/include/linux/spinlock_types.h中定义。定义如下:
typedef struct { raw_spinklock_t raw_lock;
#ifdefined(CONFIG_PREEMPT) && defined(CONFIG_SMP)
unsigned int break_lock;
#endif
} spinlock_t;
成员raw_lock:该成员变量是自旋锁数据类型的核心,它展开后实质上是一个Volatileunsigned类型的变量。具体的锁定过程与它密切相关,该变量依赖于内核选项CONFIG_SMP。(是否支持多对称处理器)
成员break_lock:同时依赖于内核选项CONFIG_SMP和CONFIG_PREEMPT(是否支持内核态抢占),该成员变量用于指示当前自旋锁是否被多个内核执行路径同时竞争、访问。
在单处理器系统下:CONFIG_SMP没有选中时,变量类型raw_spinlock_t退化为一个空结构体。相应的接口函数也发生了退化。相应的加锁函数spin_lock()和解锁函数spin_unlock()退化为只完成禁止内核态抢占、使能内核态抢占。
在多处理器系统下:选中CONFIG_SMP时,核心变量raw_lock的数据类型raw_lock_t在文件中src/include/asm-i386/spinlock_types.h中定义如下:
typedef struct { volatileunsigned int slock;} raw_spinklock_t;
从定义中可以看出该数据结构定义了一个内核变量,用于计数工作。当结构中成员变量slock的数值为1时,表示自旋锁处于非锁定状态,可以使用。否则,表示处于锁定状态,不可以使用。
普通自旋锁的接口函数:
spin_lock_init(lock) //声明自旋锁是,初始化为锁定状态
spin_lock(lock)//锁定自旋锁,成功则返回,否则循环等待自旋锁变为空闲
spin_unlock(lock) //释放自旋锁,重新设置为未锁定状态
spin_is_locked(lock) //判断当前锁是否处于锁定状态。若是,返回1.
spin_trylock(lock) //尝试锁定自旋锁lock,不成功则返回0,否则返回1
spin_unlock_wait(lock) //循环等待,直到自旋锁lock变为可用状态。
spin_can_lock(lock) //判断该自旋锁是否处于空闲状态。
普通自旋锁总结:自旋锁设计用于多处理器系统。当系统是单处理器系统时,自旋锁的加锁、解锁过程分为别退化为禁止内核态抢占、使能内核态抢占。在多处理器系统中,当锁定一个自旋锁时,需要首先禁止内核态抢占,然后尝试锁定自旋锁,在锁定失败时执行一个死循环等待自旋锁被释放;当解锁一个自旋锁时,首先释放当前自旋锁,然后使能内核态抢占。
2.2、自旋锁的变种
在前面讨论spin_lock很好的解决了多处理器之间的并发问题。但是如果考虑如下一个应用场景:处理器上的当前进程A要对某一全局性链表g_list进行操作,所以在操作前调用了spin_lock获取锁,然后再进入临界区。如果在临界区代码当中,进程A所在的处理器上发生了一个外部硬件中断,那么这个时候系统必须暂停当前进程A的执行转入到中断处理程序当中。假如中断处理程序当中也要操作g_list,由于它是共享资源,在操作前必须要获取到锁才能进行访问。因此当中断处理程序试图调用spin_lock获取锁时,由于该锁已经被进程A持有,中断处理程序将会进入忙等状态(自旋)。从而就会出现大问题了:中断程序由于无法获得锁,处于忙等(自旋)状态无法返回;由于中断处理程序无法返回,进程A也处于没有执行完的状态,不会释放锁。因此这样导致了系统的死锁。即spin_lock对存在中断源的情况是存在缺陷的,因此引入了它的变种。
spin_lock_irq(lock)
spin_unlock_irq(lock)
相比于前面的普通自旋锁,它在上锁前增加了禁用中断的功能,在解锁后,使能了中断。
2.3、读写自旋锁rwlock
应用背景:前面说的普通自旋锁spin_lock类的函数在进入临界区时,对临界区中的操作行为不细分。只要是访问共享资源,就执行加锁操作。但是有时候,比如某些临界区的代码只是去读这些共享的数据,并不会改写,如果采用spin_lock()函数,就意味着,任意时刻只能有一个进程可以读取这些共享数据。如果系统中有大量对这些共享资源的读操作,很明显spin_lock将会降低系统的性能。因此提出了读写自旋锁rwlock的概念。对照普通自旋锁,读写自旋锁允许多个读者进程同时进入临界区,交错访问同一个临界资源,提高了系统的并发能力,提升了系统的吞吐量。
读写自旋锁有数据结构rwlock_t来表示。定义在…/spinlock_types.h中
读写自旋锁的接口函数:
DEFINE_RWLOCK(lock) //声明读写自旋锁lock,并初始化为未锁定状态
write_lock(lock) //以写方式锁定,若成功则返回,否则循环等待
write_unlock(lock) //解除写方式的锁定,重设为未锁定状态
read_lock(lock) //以读方式锁定,若成功则返回,否则循环等待
read_unlock(lock) //解除读方式的锁定,重设为未锁定状态
读写自旋锁的工作原理:
对于读写自旋锁rwlock,它允许任意数量的读取者同时进入临界区,但写入者必须进行互斥访问。一个进程要进行读,必须要先检查是否有进程正在写入,如果有,则自旋(忙等),否则获得锁。一个进程要进程写,必须要先检查是否有进程正在读取或者写入,如果有,则自旋(忙等)否则获得锁。即读写自旋锁的应用规则如下:
(1)如果当前有进程正在写,那么其他进程就不能读也不能写。
(2)如果当前有进程正在读,那么其他程序可以读,但是不能写。
2.4、顺序自旋锁seqlock
应用背景:顺序自旋锁主要用于解决自旋锁同步机制中,在拥有大量读者进程时,写进程由于长时间无法持有锁而被饿死的情况,其主要思想是:为写进程提高更高的优先级,在写锁定请求出现时,立即满足写锁定的请求,无论此时是否有读进程正在访问临界资源。但是新的写锁定请求不会,也不能抢占已有写进程的写锁定。
顺序锁的设计思想:对某一共享数据读取时不加锁,写的时候加锁。为了保证读取的过程中不会因为写入者的出现导致该共享数据的更新,需要在读取者和写入者之间引入一个整形变量,称为顺序值sequence。读取者在开始读取前读取该sequence,在读取后再重新读取该值,如果与之前读取到的值不一致,则说明本次读取操作过程中发生了数据更新,读取操作无效。因此要求写入者在开始写入的时候更新。
顺序自旋锁由数据结构seqlock_t表示,定义在src/include/linux/seqlcok.h
顺序自旋锁访问接口函数:
seqlock_init(seqlock) //初始化为未锁定状态
read_seqbgin()、read_seqretry() //保证数据的一致性
write_seqlock(lock) //尝试以写锁定方式锁定顺序锁
write_sequnlock(lock) //解除对顺序锁的写方式锁定,重设为未锁定状态。
顺序自旋锁的工作原理:写进程不会被读进程阻塞,也就是,写进程对被顺序自旋锁保护的临界资源进行访问时,立即锁定并完成更新工作,而不必等待读进程完成读访问。但是写进程与写进程之间仍是互斥的,如果有写进程在进行写操作,其他写进程必须循环等待,直到前一个写进程释放了自旋锁。顺序自旋锁要求被保护的共享资源不包含有指针,因为写进程可能使得指针失效,如果读进程正要访问该指针,将会出错。同时,如果读者在读操作期间,写进程已经发生了写操作,那么读者必须重新读取数据,以便确保得到的数据是完整的。
(3)信号量机制(semaphore)
应用背景:前面介绍的自旋锁同步机制是一种“忙等”机制,在临界资源被锁定的时间很短的情况下很有效。但是在临界资源被持有时间很长或者不确定的情况下,忙等机制则会浪费很多宝贵的处理器时间。针对这种情况,linux内核中提供了信号量机制,此类型的同步机制在进程无法获取到临界资源的情况下,立即释放处理器的使用权,并睡眠在所访问的临界资源上对应的等待队列上;在临界资源被释放时,再唤醒阻塞在该临界资源上的进程。另外,信号量机制不会禁用内核态抢占,所以持有信号量的进程一样可以被抢占,这意味着信号量机制不会给系统的响应能力,实时能力带来负面的影响。
信号量设计思想:除了初始化之外,信号量只能通过两个原子操作P()和V()访问,也称为down()和up()。down()原子操作通过对信号量的计数器减1,来请求获得一个信号量。如果操作后结果是0或者大于0,获得信号量锁,任务就可以进入临界区。如果操作后结果是负数,任务会放入等待队列,处理器执行其他任务;对临界资源访问完毕后,可以调用原子操作up()来释放信号量,该操作会增加信号量的计数器。如果该信号量上的等待队列不为空,则唤醒阻塞在该信号量上的进程。
信号量的分类:
3.1、普通信号量
普通信号量由数据结构struct semaphore来表示,定义在src/inlcude/ asm-i386/semaphore.h中.
信号量(semaphore)定义如下:
<include/linux/semaphore.h>
struct semaphore{
spinlock_t lock; //自旋锁,用于实现对count的原子操作
unsigned int count; //表示通过该信号量允许进入临界区的执行路径的个数
struct list_head wait_list; //用于管理睡眠在该信号量上的进程
};
普通信号量的接口函数:
sema_init(sem,val) //初始化信号量计数器的值为val
int_MUTEX(sem) //初始化信号量为一个互斥信号量
down(sem) //锁定信号量,若不成功,则睡眠在等待队列上
up(sem) //释放信号量,并唤醒等待队列上的进程
DOWN操作:linux内核中,对信号量的DOWN操作有如下几种:
void down(struct semaphore *sem); //不可中断
int down_interruptible(struct semaphore *sem);//可中断
int down_killable(struct semaphore *sem);//睡眠的进程可以因为受到致命信号而被唤醒,中断获取信号量的操作。
int down_trylock(struct semaphore *sem);//试图获取信号量,若无法获得则直接返回1而不睡眠。返回0则 表示获取到了信号量
int down_timeout(struct semaphore *sem,long jiffies);//表示睡眠时间是有限制的,如果在jiffies指明的时间到期时仍然无法获得信号量,则将返回错误码。
在以上四种函数中,驱动程序使用的最频繁的就是down_interruptible函数
UP操作:LINUX内核只提供了一个up函数
void up(struct semaphore *sem)
加锁处理过程:加锁过程由函数down()完成,该函数负责测试信号量的状态,在信号量可用的情况下,获取该信号量的使用权,否则将当前进程插入到当前信号量对应的等待队列中。函数调用关系如下:down()->__down_failed()->__down.函数说明如下:
down()功能介绍:该函数用于对信号量sem进行加锁,在加锁成功即获得信号的使用权是,直接退出,否则,调用函数__down_failed()睡眠到信号量sem的等待队列上。__down()功能介绍:该函数在加锁失败时被调用,负责将进程插入到信号量 sem的等待队列中,然后调用调度器,释放处理器的使用权。
解锁处理过程:普通信号量的解锁过程由函数up()完成,该函数负责将信号计数器count的值增加1,表示信号量被释放,在有进程阻塞在该信号量的情况下,唤醒等待队列中的睡眠进程。
3.2读写信号量(rwsem)
应用背景:为了提高内核并发执行能力,内核提供了读入者信号量和写入者信号量。它们的概念和实现机制类似于读写自旋锁。
工作原理:该信号量机制使得所有的读进程可以同时访问信号量保护的临界资源。当进程尝试锁定读写信号量不成功时,则这些进程被插入到一个先进先出的队列中;当一个进程访问完临界资源,释放对应的读写信号量是,该进程负责将该队列中的进程按一定的规则唤醒。
唤醒规则:唤醒排在该先进先出队列中队首的进程,在被唤醒进程为写进程的情况下,不再唤醒其他进程;在唤醒进程为读进程的情况下,唤醒其他的读进程,直到遇到一个写进程(该写进程不被唤醒)
读写信号量的定义如下:
<include/linux/rwsem-spinlock.h>
sturct rw_semaphore{
__s32 activity; //用于表示读者或写者的数量
spinlock_t wait_lock;
struct list_head wait_list;
};
读写信号量相应的接口函数
读者up、down操作函数:
void up_read(Sturct rw_semaphore *sem);
void __sched down_read(Sturct rw_semaphore *sem);
Int down_read_trylock(Sturct rw_semaphore *sem);
写入者up、down操作函数:
void up_write(Sturct rw_semaphore *sem);
void __sched down_write(Sturct rw_semaphore *sem);
int down_write_trylock(Sturct rw_semaphore *sem);
3.3、互斥信号量
在linux系统中,信号量的一个常见的用途是实现互斥机制,这种情况下,信号量的count值为1,也就是任意时刻只允许一个进程进入临界区。为此,linux内核源码提供了一个宏DECLARE_MUTEX,专门用于这种用途的信号量定义和初始化
<include/linux/semaphore.h>
#define DECLARE_MUTEX(name) \
structsemaphore name=__SEMAPHORE_INITIALIZER(name,1)
(4)互斥锁mutex
Linux内核针对count=1的信号量重新定义了一个新的数据结构struct mutex,一般都称为互斥锁。内核根据使用场景的不同,把用于信号量的down和up操作在struct mutex上做了优化与扩展,专门用于这种新的数据类型。
(5)RCU
RCU概念:RCU全称是Read-Copy-Update(读/写-复制-更新),是linux内核中提供的一种免锁的同步机制。RCU与前面讨论过的读写自旋锁rwlock,读写信号量rwsem,顺序锁一样,它也适用于读取者、写入者共存的系统。但是不同的是,RCU中的读取和写入操作无须考虑两者之间的互斥问题。但是写入者之间的互斥还是要考虑的。
RCU原理:简单地说,是将读取者和写入者要访问的共享数据放在一个指针p中,读取者通过p来访问其中的数据,而读取者则通过修改p来更新数据。要实现免锁,读写双方必须要遵守一定的规则。
读取者的操作(RCU临界区)
对于读取者来说,如果要访问共享数据。首先要调用rcu_read_lock和rcu_read_unlock函数构建读者侧的临界区(read-side critical section),然后再临界区中获得指向共享数据区的指针,实际的读取操作就是对该指针的引用。
读取者要遵守的规则是:(1)对指针的引用必须要在临界区中完成,离开临界区之后不应该出现任何形式的对该指针的引用。(2)在临界区内的代码不应该导致任何形式的进程切换(一般要关掉内核抢占,中断可以不关)。
写入者的操作
对于写入者来说,要写入数据,首先要重新分配一个新的内存空间做作为共享数据区。然后将老数据区内的数据复制到新数据区,并根据需要修改新数据区,最后用新数据区指针替换掉老数据区的指针。写入者在替换掉共享区的指针后,老指针指向的共享数据区所在的空间还不能马上释放(原因后面再说明)。写入者需要和内核共同协作,在确定所有对老指针的引用都结束后才可以释放老指针指向的内存空间。为此,写入者要做的操作是调用call_rcu函数向内核注册一个回调函数,内核在确定所有对老指针的引用都结束时会调用该回调函数,回调函数的功能主要是释放老指针指向的内存空间。Call_rcu函数的原型如下:
Void call_rcu(struct rcu_head *head,void (*func)(struct rcu_head *rcu));
内核确定没有读取者对老指针的引用是基于以下条件的:系统中所有处理器上都至少发生了一次进程切换。因为所有可能对共享数据区指针的不一致引用一定是发生在读取者的RCU临界区,而且临界区一定不能发生进程切换。所以如果在CPU上发生了一次进程切换切换,那么所有对老指针的引用都会结束,之后读取者再进入RCU临界区看到的都将是新指针。
老指针不能马上释放的原因:这是因为系统中爱可能存在对老指针的引用,者主要发生在以下两种情况:(1)一是在单处理器范围看,假设读取者在进入RCU临界区后,刚获得共享区的指针之后发生了一个中断,如果写入者恰好是中断处理函数中的行为,那么当中断返回后,被中断进程RCU临界区中继续执行时,将会继续引用老指针。(2)另一个可能是在多处理器系统,当处理器A上的一个读取者进入RCU临界区并获得共享数据区中的指针后,在其还没来得及引用该指针时,处理器B上的一个写入者更新了指向共享数据区的指针,这样处理器A上的读取者也饿将引用到老指针。
RCU特点:由前面的讨论可以知道,RCU实质上是对读取者与写入者自旋锁rwlock的一种优化。RCU的可以让多个读取者和写入者同时工作。但是RCU的写入者操作开销就比较大。在驱动程序中一般比较少用。
为了在代码中使用RCU,所有RCU相关的操作都应该使用内核提供的RCU API函数,以确保RCU机制的正确使用,这些API主要集中在指针和链表的操作。
下面是一个RCU的典型用法范例:
<span style="font-size:14px;">//<span style="font-size:14px;">假设struct shared_data是一个在读取者和写入者之间共享的受保护数据
Struct shared_data{
Int a;
Int b;
Struct rcu_head rcu;
};
//读取者侧的代码
Static void demo_reader(struct shared_data *ptr)
{
Struct shared_data *p=NULL;
Rcu_read_lock();
P=rcu_dereference(ptr);
If(p)
Do_something_withp(p);
Rcu_read_unlock();
}
//写入者侧的代码
Static void demo_del_oldptr(struct rcu_head *rh) //回调函数
{
Struct shared_data *p=container_of(rh,struct shared_data,rcu);
Kfree(p);
}
Static void demo_writer(struct shared_data *ptr)
{
Struct shared_data *new_ptr=kmalloc(…);
…
New_ptr->a=10;
New_ptr->b=20;
Rcu_assign_pointer(ptr,new_ptr);//用新指针更新老指针
Call_rcu(ptr->rcu,demo_del_oldptr); 向内核注册回调函数,用于删除老指针指向的内存空间
}
</span></span>
(6)完成接口completion
Linux内核还提供了一个被称为“完成接口completion”的同步机制,该机制被用来在多个执行路径间作同步使用,也即协调多个执行路径的执行顺序。在此就不展开了。
6.linux内核启动为什么要进入SVC模式
linux让CPU进入SVC模式,为一种管理员模式,部分ARMcpu只有进入该模式才能够关闭中断。
7.如何用mutex实现sem,在不更改mutex的情况
使用用以结构体封装一个mutex,一个cond(条件变量),和一个代表val的值。
利用mutex来保证pv操作的原子性,p时候上锁判断是否为0,为零改变发送cond信号,+1还锁,v的时候上锁-1,判断是否为零,为零等待cond信号(会自动还锁),不为零还锁,利用条件变量将val==0时原地自旋的线程释放