Skip to content
平兄聊技术
Go back

同步异步与阻塞非阻塞(下)

上篇(去上篇看看)说到,我们将去内核代码看看当ssize_t read(int, void*, size_t)调用发生在一个socket上时,是不是结合了等待数据(阻塞)、数据转移(同步)2步行为。场景如前,复习一下。

在客户端连接服务器的场景中,Client 与 Server 已经建立了一条TCP连接,此时连接状态为 ESTABLISHED,但是还没有数据传输。此时,Server 在等待 Client 发送数据,即调用了ssize_t read(int, void*, size_t)。 为描述方便,也没有使用多路复用,也没有对这条连接的fd设置O_NONBLOCK.

源码走读

read 初探

read(2)调用在内核代码 fs/read_write.c中,SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)。一层层的抽丝剥茧,我们可以定位到ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos)。其中的关键代码是这样的。

图片

注意到493行至498行。在 struct file 中,为read 操作定义了对应的函数指针,优先使用 f_op->read 操作,当 f_op->read 为空且 f_op->read_iter 不为空时,采用 new_sync_read 。从命名我们也能看出一些端倪, read_iter 很可能是针对流式的读, new_sync_read 这个函数名也符合我们常说的”Linux上只有同步读“这一论点。那我们先假设就是进入了 new_sync_read 分支吧。(又开始假设了。不妨记下来,后面我们验证这些假设,并有可能修订这些不完善的说法)。

new_sync_read 函数追踪下去,很快,便发现是调用了 file->f_op->read_iter 指向的函数。既然这个 file 是由 socket(2) 创建,那对应的函数指针也应该是在此时赋值的。我们去看看socket 的创建过程。

socket 的创建

调用int socket(int, int int)会为我们创建一个socket,按照”一切皆文件“的传统,返回了对应的fd。看看内核真正创建socket 的地方net/socket.c

图片

不难分析出,函数主要做了2件事。

  1. sock_create创建struct socket*对象(1501行)。
  2. sock_map_fd将上步的创建的socket 绑定到一个struct file*上,并用一个int 与之映射(1505行)。

既然我们的目标是看 file->f_op 中的内容,那便跳过socket的创建,进入 struct socket*struct file* 绑定的操作,sock_map_fd.

一层层看下来,终于在 sock_alloc_file 函数中,

图片

注意到在412行,调用了 alloc_file_pseudo 时,参数给出了 struct socket*[sock] ,及 const struct file_operations*[&socket_file_ops] 。猜想,一定在该函数某个地方,将 socket_file_ops 绑定到了对应文件的 f_op 上。这里的验证不再赘述,交给有兴趣的小伙伴们自行验证。

看一眼 socket_file_ops 的定义,其中152行,正是我们要找的内容。

图片

终于说到socket接收数据

追踪sock_read_iter函数,发现调用了sock_recvmsg

int sock_recvmsg(struct socket *sock, struct msghdr *msg, int flags)
{ 
    int err = security_socket_recvmsg(sock, msg, msg_data_left(msg), flags); 
    return err ?: sock_recvmsg_nosec(sock, msg, flags);
}

security_socket_recvmsg是和安全相关的,如果编译内核的时候没有配置相关选项,该处代码会为空实现,不是主路径,我们还是看看sock_recvmsg_nosec的实现吧。

根据 sock_recvmsg_nosec,后续是调用 sock->ops->recvmsg 指向的函数。该函数指针是在内核初始化协议栈的时候赋值的,而不是socket 创建时,涉及到协议栈的初始化,暂不讨论。但是没关系,这里我用了一个小trick。

通过在内核代码中全文搜索,找到 net/ipv4/af_inet.c 中,

图片

注意到这里的1039行,赋值成了inet_recvmsg函数。所以sock_recvmsg_nosec应该会调用inet_recvmsg。从命名上看,inet_recvmsg是接收ipv4相关协议的。果然在其中,我们也看到了tcp_recvmsgudp_recvmsg函数。既然我们的场景是一个TCP连接,那就果断进入tcp_recvmsg函数。

在进入前,我们注意一点,有参数 nonblock 了。关于该参数的来源,是调用 inet_recvmsgflags & MSG_DONTWAIT 得到。至于 flags 的来源,小伙伴们可以自行溯源。

经过一番追踪,终于到了我们的目的地 tcp_recvmsg_locked

static int tcp_recvmsg_locked(struct sock *sk, struct msghdr *msg, size_t len, 
    int nonblock, int flags, 
    struct scm_timestamping_internal *tss, 
    int *cmsg_flags)
{ 
    struct tcp_sock *tp = tcp_sk(sk); 
    // ... 
    timeo = sock_rcvtimeo(sk, nonblock); 
    // .. 
    target = sock_rcvlowat(sk, flags & MSG_WAITALL, len); 
    // .. 
    skb_queue_walk(&sk->sk_receive_queue, skb) {
        //... 
        if (copied >= target) { 
            /* Do not sleep, just process backlog. */ 
            release_sock(sk); 
            lock_sock(sk); 
        } else { 
            sk_wait_data(sk, &timeo, last); 
        }
        // ..
        if (!(flags & MSG_TRUNC)) {
            err = skb_copy_datagram_msg(skb, offset, msg, used); 
            if (err) { 
                /* Exception. Bailout! */
                if (!copied)
                    copied = -EFAULT;
                break;
            }
        }

先看sock_rcvtimeo。这里是在计算recv操作的超时时间。如果是 nonblock,timeout 为 0,否则为sk->sk_rcvtimeo

随后,skb_queue_walk开始遍历协议栈的sk队列。

在遍历的过程中,有sk_wait_data划重点)分支。如果遍历无数据,则进入该分支。该函数使用了上步得到的超时时间,将当前task(Linux 中进程、线程均为task,调度任务)设置为TASK_INTERRUPTIBLE状态(当数据到来时,被信号/wake_up调用唤醒),使用 schedule_timeout 将当前进程挂起,直到超时(上步计算的 timeout),或提前返回(因为数据到来,产生了信号唤醒之)。

另一方面,当sk队列中有数据时,使用了skb_copy_datagram_msg划重点)将队列中数据,拷贝至用户态地址。

层层剖析skb_copy_datagram_msg,最终调用了lib/iov_iter.c中的size_t _copy_to_iter(const void *addr, size_t bytes, struct iov_iter *i)。其中的copyout不难看出,该函数正是将数据拷贝至某一用户态地址经过地址映射后的物理地址。

图片

小结

通过以上源码分析,我们看到在read(2)调用内,同时用到了

  1. sk_wait_data等待数据的到来
  2. skb_copy_datagram_msg将数据拷贝至用户空间地址

拾遗

最后,我们在这一路上,还可以得到一些其他的知识点。

  1. 从函数命名sock_recvmsg不难想象,read(2)调用,其实是recvmsg(2)调用的一个特例。
  2. 也可以在调用setsockopt(2)时,使用SO_RCVTIMEO设置阻塞时间。
  3. 参数使用SO_RCVLOWAT调用setsockopt(2),设置接收的最低水位线,当缓冲区超过该水位线,才会”可读“。

最最后,我们在分析 vfs_read 调用时,猜测是进入了 new_sync_read 分支,后续的分析在这个调用中找到了我们需要的函数,验证了该猜测。但”Linux上只有同步读“这一说法,颇待商榷。在传统的POSIX规范中,确实没有接口设置 SYNC/ASYNC 行为。所以在旧版本的Linux,既然只有同步操作,那也无所谓去区分”同步“、”异步“的说法了。在Windows上,有所谓的OVERLAPPED 异步操作接口,是谓 IOCP。调用者向内核发起读请求,并提供回调函数。调用立马返回。内核会把数据拷贝到用户地址后,再调用回调函数,用户程序再处理数据。

不过随着 io_uring 的不断进化,真·异步IO 也不再是Windows程序独享了……


Share this post on:


Previous Post
优秀的人
Next Post
同步异步与阻塞非阻塞(上)