READ_ONCE、smp_store_release在io_uring中实例分析
文章目录
- 1. READ_ONCE
- 1.1 定义
- 1.2 READ_ONCE使用场景
- 1.3 为什么用READ_ONCE而不直接用volatile定义变量
- 2. smp_store_release
- 2.1 定义
- 2.2 smp_store_release和smp_load_acquire使用场景
- 2.2.1 标志位模型
- 2.2.2 环形缓冲模型
- 3. io_uring中sq的tail读写分析
- 3.1 用户层和内核层数据结构
- 3.2 用户写sq->ktail
- 3.3 内核读sq.tail
内核:5.15
1. READ_ONCE
1.1 定义
#define READ_ONCE(x) \
({ \compiletime_assert_rwonce_type(x); \__READ_ONCE(x); \
})
#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
编译时会检查操作数的类型,如果不是基本类型编译就报错:
#define compiletime_assert_rwonce_type(t) \compiletime_assert(__native_word(t) || sizeof(t) == sizeof(long long), \"Unsupported access size for {READ,WRITE}_ONCE().")#define __native_word(t) \(sizeof(t) == sizeof(char) || sizeof(t) == sizeof(short) || \sizeof(t) == sizeof(int) || sizeof(t) == sizeof(long))
从上面可见基本类型为char、short、int、long、long long。
#ifndef __READ_ONCE
#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
#endif
__READ_ONCE的核心是volatile。稍微提一下,volatile原本是在寄存器硬件易变时使用的,后来也扩展到其他使用了,但内核文档中反对广泛直接使用volatile作用于变量的定义处。
1.2 READ_ONCE使用场景
- 中断上下文会更改的变量
- 多线程环境更改的变量,多核心时
- CPU不感知即会变化的变量,如硬件相关的寄存器,DMAC更改的内存
- 用户空间和内核空间映射的内存,如io_uring中的head/tail等
怎么记?凡是超乎编译器按照单CPU的逻辑处理的变量就需要使用,甚至还需要处理乱序问题。以上4点中的最后一点,用户空间和内核空间的编译都是独立的,互相都感知不到对方的读或写存在,内核的编译器可能认为没有地方会修改tail,因此就不从内存或cache读,而使用寄存器中的值,这种进行了优化也可定是会有问题的。
1.3 为什么用READ_ONCE而不直接用volatile定义变量
编译器需要对代码进行优化,一旦变量直接使用了volatile修饰定义的变量,就意味编译器放弃了对它的任何使用地方的优化。而READ_ONCE是临时地转换为volatile,切实不能按照单CPU的逻辑进行优化的地方才使用,属于更灵活的使用方法,简单说为了优化最大化。
2. smp_store_release
2.1 定义
#ifndef smp_store_release
#define smp_store_release(p, v) \
do { \compiletime_assert_atomic_type(*p); \barrier(); \WRITE_ONCE(*p, v); \
} while (0)
#endif
和WRITE_ONCE相比,除了编译时的变量类型检查外,多了内存屏障,可确保先后的顺序。
2.2 smp_store_release和smp_load_acquire使用场景
使用场景多为生成者消费者模型。
2.2.1 标志位模型
static int shared_flag = 0;
static int shared_data = 0;void producer(void) {// 生产数据(确保写入操作不会被重排序到flag之后)shared_data = 123;// 发布标志位(release语义保证数据写入对其他核可见)smp_store_release(&shared_flag, 1);
}void consumer(void) {// 获取标志位(acquire语义保证读取到最新值)int flag = smp_load_acquire(&shared_flag);if (flag == 1) {// 此时shared_data的值必然为生产者写入的123printk("Consumed data: %d\n", shared_data);}
}
2.2.2 环形缓冲模型
Circular Buffers的使用场景如下:
- 生产者
spin_lock(&producer_lock);unsigned long head = buffer->head;
/* The spin_unlock() and next spin_lock() provide needed ordering. */
unsigned long tail = READ_ONCE(buffer->tail);
if (CIRC_SPACE(head, tail, buffer->size) >= 1) {/* insert one item into the buffer */struct item *item = buffer[head];produce_item(item);smp_store_release(buffer->head,(head + 1) & (buffer->size - 1));/* wake_up() will make sure that the head is committed before* waking anyone up */wake_up(consumer);
}spin_unlock(&producer_lock);
CPU必须在head索引使其对消费者可用之前写入新项目的内容,同时CPU必须在唤醒消费者之前写入修改后的head索引。
- 消费者
spin_lock(&consumer_lock);/* Read index before reading contents at that index. */
unsigned long head = smp_load_acquire(buffer->head);
unsigned long tail = buffer->tail;if (CIRC_CNT(head, tail, buffer->size) >= 1) {/* extract one item from the buffer */struct item *item = buffer[tail];consume_item(item);/* Finish reading descriptor before incrementing tail. */smp_store_release(buffer->tail,(tail + 1) & (buffer->size - 1));
}spin_unlock(&consumer_lock);
CPU在读取新元素之前确保索引是最新的,然后在写入新的尾指针之前应确保CPU已完成读取该元素,这将擦除该元素。
3. io_uring中sq的tail读写分析
3.1 用户层和内核层数据结构
//-----------------用户层--------------------
struct io_uring_sq { //用户层squnsigned *khead;//指向内核io_uring中的head成员 -->11unsigned *ktail;...unsigned sqe_head;//用户层缓存 22unsigned sqe_tail;...
};//-------------------内核----------------------
struct io_ring_ctx {//内核环上下文...struct io_rings *rings; ==》在下面...unsigned cached_sq_head;//内核层缓存 33...
};struct io_rings {
struct io_uring sq, cq; ==》在下面
};struct io_uring {u32 head ____cacheline_aligned_in_smp;//被用户层指向 <--11u32 tail ____cacheline_aligned_in_smp;
};
值得说明的是:
- 用户层和内核层均尽可能得使用cache变量来代替内核中映射的共享变量如上注释为22/33处,从而减少使用内存屏障带来的同步开销。
- 内核的struct io_uring结构中head和tail变量均使用____cacheline_aligned_in_smp对齐,从而使得用户态修改tail和内核态修改head能错开不在同一个cache line中,减少各自变更带给彼此cache一致性同步开销。
3.2 用户写sq->ktail
提交展开如下:
ret = io_uring_submit(&ring);》__io_uring_submit_and_wait》__io_uring_flush_sq 》if (!(ring->flags & IORING_SETUP_SQPOLL))*sq->ktail = tail; (1)elseio_uring_smp_store_release(sq->ktail, tail); (2)》__io_uring_submit 》__sys_io_uring_enter (3)》无需进入则直接返回 (4)
分析__io_uring_submit中的逻辑,发现case(1)会进入(3),而 (2)则进入 (4)。
- 标注(2)设置了IORING_SETUP_SQPOLL标志,会启动内核线程来提交,无须系统调用进入。对于共享全局变量(sq->ktail指向)用户进程和内核线程一个读一个写,写者可以使用上面的生产者模型,使用了用户层的smp_store_release API。
- 标注(1)进入(3)执行系统调用,和内核对sq的tail的读访问是串行的,因而不必加内存屏障,直接使用*sq->ktail = tail而未使用WRITE_ONCE,待进一步分析。
3.3 内核读sq.tail
直接搜索到的结果为:
static inline unsigned int io_sqring_entries(struct io_ring_ctx *ctx)
{struct io_rings *rings = ctx->rings;/* make sure SQ entry isn't read before tail */return smp_load_acquire(&rings->sq.tail) - ctx->cached_sq_head;
}static inline bool io_sqring_full(struct io_ring_ctx *ctx)
{struct io_rings *r = ctx->rings;return READ_ONCE(r->sq.tail) - ctx->cached_sq_head == ctx->sq_entries;
}
既有smp_load_acquire又有READ_ONCE,再往上捋一捋。
- 先看READ_ONCE调用栈:
io_uring_enter》io_sqpoll_wait_sq》io_sqring_full》READ_ONCE
系统调用下来是串行的,使用READ_ONCE就可以了,不必使用更多cache同步开销smp_load_acquire。
- 再看smp_load_acquire调用栈:
内核线程会调用io_sqring_entries,因此使用消费者模型的smp_load_acquire:
io_sq_thread》__io_sq_thread》io_submit_sqes》io_sqring_entries》smp_load_acquire
系统调用io_uring_enter也会调用io_sqring_entries,这里应该是可以用READ_ONCE,但不适合上面的内核线程,作为同一个函数,使用smp_load_acquire可同时适用两种情形。
io_uring_enter》io_submit_sqes》io_sqring_entries》smp_load_acquire