• 好好学习,天天向上~
  • 欢迎欢迎~

C语言陷阱与技巧19节,#define atomic_read(v) ((v)->counter + 0)在宏定义后面加0有什么好处?怎样自定义一个自己的锁?

c语言 lcc 217℃ 0评论

在阅读 Linux 内核源码时,发现了两个宏,相关的C语言代码如下,请看:

#define atomic_read(v)      ((v)->counter + 0)
#define atomic64_read(v)    ((v)->counter + 0)


这两个宏接收一个结构体参数,该结构体的C语言定义如下:

typedef struct { volatile int counter; } atomic_t;
typedef struct { volatile long counter; } atomic64_t;


这两个宏可以提供“原子操作”级的读数据操作。一开始看到这个宏的时候,我搞不懂为何要在最后“+0”,不过仔细想想,这么做至少有两个好处。

宏定义后 “+0”的技巧

首先,在 atomic_read() 宏定义后“+0”可以避免 atomic_read() 宏被当作“左值”。根据改宏的名字,应该能够知道它是“原子的”读取,而一个被读取的数据再做“左值”显然是不合适的,如果没有后面的 “+0”,下面这样误写的C语言代码,编译器是不会报错的:

if (atomic_read(v) = 32) {
    ...
}

当然,也可以使用下面这样的宏定义避免 atomic_read() 宏被当作左值:

#define atomic_read(v)      (+(v)->counter)

即加上一个“正号”,不过这么做显然没有在后面“+0”好,相信读者应该明白,这里就不赘述了。

按照C语言标准,一个宏只要名字一样,参数类型一样,逻辑一样,出现重复的宏定义时完全没有问题的,不过出现重复代码对维护来说是一件很不好的事。在宏后面“+0”的另外一个好处就是可以尽可能的避免重复的宏定义。请看:

//atomic.h
#define atomic_read(v)      ((v)->counter + 0)
#define atomic64_read(v)    ((v)->counter)

//some source file that includes atomic.h
#define atomic_read(v)      ((v)->counter) //redefinition error 
#define atomic64_read(v)    ((v)->counter) //no redefinition error

C语言程序开发中的原子操作

我们再来说说C语言程序开发中的“原子操作”。相信不少朋友都听说过“锁”的概念,它主要用于避免一些共享资源被多个线程并发访问时,出现数据错误的情况。而“原子操作”是锁的基石,或者换句话说,“锁”是依靠原子操作实现的。

众所周知,“原子”是组成万物的微小颗粒,一般认为原子已经足够小,无法再被分割。与之对应,C语言中的“原子操作”则是不能再被分割的指令。那么,原子操作的意义是什么呢?假设在某个C语言程序中定义了一个全局变量 i,如果有两个线程同时访问 i,并执行“加一”操作,如果 i 的初值为 0,我们当然希望这一过程是这样的:

但是,如果没有对 i 做任何保护,实际上非常有可能是下面这样的执行流程:

因为访问i和对其加一是独立的两个过程,线程1和线程2完全可能在 i 的值增加之前读取到了它的初值,然后各自加一,这就会导致不期望的结果出现:两个线程执行完毕后,全局变量 i 的值本来应该是 2 的,结果却为 1 了。

不过,如果访问加一这两个操作是原子操作,上面那种竞争情况就不会出现了,整个过程只有可能是下面这两种情况之一:

最后必定会得到预期结果(i==2),因为访问加一是一个原子操作,这个过程不可能被分割,也就不会出现不预期的结果了。

原子操作的“陷阱”与“小技巧”

可能初学者会认为C语言程序中,如果代码只有一行,那必定是原子操作。这其实是一个较为致命的“陷阱”,大多数机器只能保证操作一个字是原子的,还有一部分机器则只能保证操作一个字节是原子的。

举个最简单的例子,请看下面这段C语言代码:

struct s{
    long    a;
    double b;
    char c[1024];
};
struct s s1, s2;

s1 = s2;

s 是一个相当大的结构体,所以 s1=s2; 虽然只有一行,它仍然不是原子操作。事实上,我们可以得到这段C语言程序的汇编代码:

容易看出,虽然 s1=s2; 只是一行C语言代码,但机器却需要若干条指令才能完成,这就非常有可能被其他线程打断。例如 s1=s2; 这条语句正在赋值,还没有完成时,其他线程读取了 s1,这显然会导致不期望的结果出现。

避免出现上述“不期望”的结果出现的方法就是对 s1 和 s2 进行保护。常用的方法是使用锁,在赋值之前加锁,赋值完成后再解锁。

lock();
s1 = s2;
unlock();

互斥锁是使用最广泛的锁之一,但是互斥锁在加锁过程中可能会睡眠,这时操作系统可能会调度其他线程运行,这对于需要较长时间加锁的情况当然是好事,但是我们仅做了赋值操作,是不希望有这样的时间开销的,针对这种情况,一个小技巧是使用位操作,自定义一个轻量级的锁:

status char status  = 0;
#define BIT_LOCK       0x01

#define        BIT_UNLOCK()    (status &= ~BIT_LOCK)
#define        BIT_LOCK()\
    do{\
        while( status & BIT_LOCK);\
        status |= BIT_LOCK;\
    }while(0)

BIT_LOCK();
s1 = s2;
BIT_UNLOCK();


一个位要么是 0,要么是 1,它的变化必定是“原子”的,因此完全可以用来自定义一个轻量级的锁。不过应该注意,BIT_LOCK() 只是一个轻量级的锁,在锁住资源的时候,它不会睡眠,而是让 CPU 保持空转等待,这期间CPU什么工作也不做。所以在 BIT_LOCK() 加锁期间,应只做一些能够快速完成的工作。

请读者思考一下,为什么在处理一些能够快速完成的工作时,使用 BIT_LOCK() 比使用互斥锁的效率反而更高呢?(可阅读我的 《Linux 学习》系列文章)

小结

本节我们通过Linux 内核中的一个宏定义知道了有时候“+0”这样看似无用的操作,也是能够提供非常不错的实用技巧的。另外,本节也讨论了原子操作,以及如何利用这一原理实现自己的锁。


C语言陷阱与技巧19节,#define atomic_read(v) ((v)->counter + 0)在宏定义后面加0有什么好处?怎样自定义一个自己的锁? - 刘冲的博客(https://blog.popkx.com) 原创版权所有,转载请注明出处。
点赞 (0)分享 (0)
发表我的评论
取消评论
表情     0 + 7 = ? (必填)