C/C++编程:通过unix域套接字在进程之间传递文件描述符

1060 篇文章 293 订阅
文件描述符在内核中扮演关键角色,尤其在进程间通信中。本文详细解析了如何通过内核实现文件描述符的传递,从fork子进程时的共享,到使用Unix域套接字进行文件描述符的发送和接收。在发送端,通过msghdr结构体和SCM_RIGHTS类型传递文件描述符,内核在接收端接收并重新关联到新的文件描述符。这一技术常用于高性能网络服务,如Nebula框架,以提高并发处理效率和稳定性。
摘要由CSDN通过智能技术生成

从内核看文件描述符传递的实现

文件描述符是内核提供的一个非常有用的技术,典型的在服务器中,主进程负责接收请求,然后把请求传递给子进程处理。

我们先看一下文件描述符传递到底是什么概念。假设主进程打开了一个文件,我们看看进程和文件系统的关系。
在这里插入图片描述
如果这时候主进程fork出一个子进程,那么架构如下。

在这里插入图片描述
我们看到主进程和子进程都指向同一个文件。那么如果这时候住进程又打开了一个文件。架构如下。
在这里插入图片描述
我们看到新打开的文件,子进程是不会再指向了的。假设文件底层的资源是TCP连接,而主进程想把这个关系同步到子进程中,即交给子进程处理,那怎么办呢?这时候就需要用到文件描述符传递。下面是我们期待的架构
在这里插入图片描述
通常主进程会close掉对应的文件描述符,即解除引用关系。不过这里我们可以不关注这个。文件描述符这种能力不是天然,需要内核支持,如果我们单纯把fd(文件描述符)当作数据传给子进程,子进程无法指向对应的文件的。下面我们如何使用这个技术并通过内核来看看如何实现这个技术。下面使用代码摘自Libuv。

int fd_to_send;
    // 核心数据结构
    struct msghdr msg;
    struct cmsghdr *cmsg;
    union {
      char data[64];
      struct cmsghdr alias;
    } scratch;
    // 获取需要发送的文件描述符
    fd_to_send = uv__handle_fd((uv_handle_t*) req->send_handle);

    memset(&scratch, 0, sizeof(scratch));

    msg.msg_name = NULL;
    msg.msg_namelen = 0;
    msg.msg_iov = iov;
    msg.msg_iovlen = iovcnt;
    msg.msg_flags = 0;

    msg.msg_control = &scratch.alias;
    msg.msg_controllen = CMSG_SPACE(sizeof(fd_to_send));

    cmsg = CMSG_FIRSTHDR(&msg);
    cmsg->cmsg_level = SOL_SOCKET;
    cmsg->cmsg_type = SCM_RIGHTS;
    cmsg->cmsg_len = CMSG_LEN(sizeof(fd_to_send));

    /* silence aliasing warning */
    {
      void* pv = CMSG_DATA(cmsg);
      int* pi = pv;
      *pi = fd_to_send;
    }
    // fd是Unix域对应的文件描述符
    int fd = uv__stream_fd(stream);
    // 发送文件描述符
    sendmsg(fd, &msg, 0);

我们看到发送文件描述符是比较复杂的,使用的主要数据结构是msghdr。把需要发送的文件描述符保存到msghdr中,并设置一些标记。然后通过Unix域发送(Unix是唯一一种支持文件描述符传递的进程间通信方式)。我们下来主要来分析内核对sendmsg的实现。

case SYS_SENDMSG:
        err = __sys_sendmsg(a0, (struct user_msghdr __user *)a1,
                    a[2], true);

该系统调用对应的是__sys_sendmsg。

long __sys_sendmsg(int fd, struct user_msghdr __user *msg, unsigned int flags,
           bool forbid_cmsg_compat)
{
    int fput_needed, err;
    struct msghdr msg_sys;
    struct socket *sock;
    // 根据fd找到socket
    sock = sockfd_lookup_light(fd, &err, &fput_needed);
    ___sys_sendmsg(sock, msg, &msg_sys, flags, NULL, 0);
}

后面的链路很长syssendmsg->__sys_sendmsg->sock_sendmsg->sock_sendmsg_nosec。

static inline int sock_sendmsg_nosec(struct socket *sock, struct msghdr *msg)
{
    int ret = INDIRECT_CALL_INET(sock->ops->sendmsg, inet6_sendmsg,
                     inet_sendmsg, sock, msg,
                     msg_data_left(msg));
    BUG_ON(ret == -EIOCBQUEUED);
    return ret;
}

我们看到最后调用sock->ops->sendmsg,我们看看Unix域对sendmsg的实现。Unix域有SOCK_STREAM和SOCK_DGRAM两种模式,我们选第一个分析就可以。

static int unix_stream_sendmsg(struct socket *sock, struct msghdr *msg,
                   size_t len)
{
    struct sock *sk = sock->sk;
    struct sock *other = NULL;
    int err, size;
    struct sk_buff *skb;
    int sent = 0;
    struct scm_cookie scm;
    bool fds_sent = false;
    int data_len;
    // 把文件描述符信息复制到scm
    scm_send(sock, msg, &scm, false);
    // 通信的对端
    other = unix_peer(sk);
    // 不断构建数据包skbuff发送,直到发送完毕
    while (sent < len) {
        // 分配一个sdk承载消息
        skb = sock_alloc_send_pskb(sk, size - data_len, data_len,
                       msg->msg_flags & MSG_DONTWAIT, &err,
                       get_order(UNIX_SKB_FRAGS_SZ));
        // 把scm复制到skb              
        err = unix_scm_to_skb(&scm, skb, !fds_sent);
        // 把数据写到skb
        err = skb_copy_datagram_from_iter(skb, 0, &msg->msg_iter, size);
        // 插入对端的消息队列
        skb_queue_tail(&other->sk_receive_queue, skb);
        // 通知对端有数据可读
        other->sk_data_ready(other);
        sent += size;
    }
    // ...
}

我们看到,整体的逻辑不负责,主要是根据数据构造skb结构体,然后插入对端消息队列,最后通知对端有消息可读,我们这里只关注文件描述符的处理。首先我们看看scm_send。

static __inline__ int scm_send(struct socket *sock, struct msghdr *msg,
                   struct scm_cookie *scm, bool forcecreds)
{
    memset(scm, 0, sizeof(*scm));
    scm->creds.uid = INVALID_UID;
    scm->creds.gid = INVALID_GID;
    unix_get_peersec_dgram(sock, scm);
    if (msg->msg_controllen <= 0)
        return 0;
    return __scm_send(sock, msg, scm);
}

int __scm_send(struct socket *sock, struct msghdr *msg, struct scm_cookie *p)
{
    struct cmsghdr *cmsg;
    int err;

    for_each_cmsghdr(cmsg, msg) {
        switch (cmsg->cmsg_type)
        {
        case SCM_RIGHTS:
            err=scm_fp_copy(cmsg, &p->fp);
            if (err<0)
                goto error;
            break;
        }
    }
}

我们看到__scm_send遍历待发送的数据,然后判断cmsg->cmsg_type的值,我们这里是SCM_RIGHTS(见最开始的使用代码),接着调用scm_fp_copy。

static int scm_fp_copy(struct cmsghdr *cmsg, struct scm_fp_list **fplp)
{
    int *fdp = (int*)CMSG_DATA(cmsg);
    struct scm_fp_list *fpl = *fplp;
    struct file **fpp;
    int i, num;

    num = (cmsg->cmsg_len - sizeof(struct cmsghdr))/sizeof(int);
    if (!fpl)
    {
        fpl = kmalloc(sizeof(struct scm_fp_list), GFP_KERNEL);
        *fplp = fpl;
        fpl->count = 0;
        fpl->max = SCM_MAX_FD;
        fpl->user = NULL;
    }
    fpp = &fpl->fp[fpl->count];
    // 遍历然后把fd对应的file保存到fpp中。
    for (i=0; i< num; i++)
    {
        int fd = fdp[i];
        struct file *file;

        if (fd < 0 || !(file = fget_raw(fd)))
            return -EBADF;
        *fpp++ = file;
        fpl->count++;
    }

    if (!fpl->user)
        fpl->user = get_uid(current_user());

    return num;
}

我们看到scm_fp_copy遍历然后把fd对应的file保存到fpp中。而fpp属于fpl属于fplp属于最开始的struct scm_cookie scm(unix_stream_sendmsg函数),即最后把fd对应的file保存到了scm中。接着我们回到unix_stream_sendmsg看unix_scm_to_skb。

static int unix_scm_to_skb(struct scm_cookie *scm, struct sk_buff *skb, bool send_fds)
{
    int err = 0;

    UNIXCB(skb).pid  = get_pid(scm->pid);
    UNIXCB(skb).uid = scm->creds.uid;
    UNIXCB(skb).gid = scm->creds.gid;
    UNIXCB(skb).fp = NULL;
    unix_get_secdata(scm, skb);
    if (scm->fp && send_fds)
        // 写到skb
        err = unix_attach_fds(scm, skb);

    skb->destructor = unix_destruct_scm;
    return err;
}

接着看unix_attach_fds。

int unix_attach_fds(struct scm_cookie *scm, struct sk_buff *skb)
{
    int i;
    // 复制到skb
    UNIXCB(skb).fp = scm_fp_dup(scm->fp);
    return 0;
}

struct scm_fp_list *scm_fp_dup(struct scm_fp_list *fpl)
{
    struct scm_fp_list *new_fpl;
    int i;

    new_fpl = kmemdup(fpl, offsetof(struct scm_fp_list, fp[fpl->count]),
              GFP_KERNEL);
    if (new_fpl) {
        for (i = 0; i < fpl->count; i++)
            get_file(fpl->fp[i]);
        new_fpl->max = new_fpl->count;
        new_fpl->user = get_uid(fpl->user);
    }
    return new_fpl;
}

至此我们看到数据和文件描述符都写到了skb中,并插入了对端的消息队列。我们接着分析对端时如何处理的。我们从recvmsg函数开始,对应Uinix域的实现时unix_stream_recvmsg。

static int unix_stream_recvmsg(struct socket *sock, struct msghdr *msg,
                   size_t size, int flags)
{
    struct unix_stream_read_state state = {
        .recv_actor = unix_stream_read_actor,
        .socket = sock,
        .msg = msg,
        .size = size,
        .flags = flags
    };

    return unix_stream_read_generic(&state, true);
}

接着看

static int unix_stream_read_generic(struct unix_stream_read_state *state,
                    bool freezable)
{
    struct scm_cookie scm;
    struct socket *sock = state->socket;
    struct sock *sk = sock->sk;
    struct unix_sock *u = unix_sk(sk);

    // 拿到一个skb
    skb = skb_peek(&sk->sk_receive_queue);
    // 出队
    skb_unlink(skb, &sk->sk_receive_queue);
    // 复制skb数据到state->msg
    state->recv_actor(skb, skip, chunk, state);
    // 处理文件描述符
    if (UNIXCB(skb).fp) {
        scm_stat_del(sk, skb);
        // 复制skb的文件描述符信息到scm
        unix_detach_fds(&scm, skb);
    }
    if (state->msg)
        scm_recv(sock, state->msg, &scm, flags);
}

内核首先通过钩子函数recv_actor把skb数据数据复制到state->msg,recv_actor对应函数是unix_stream_read_actor。

static int unix_stream_read_actor(struct sk_buff *skb,
                  int skip, int chunk,
                  struct unix_stream_read_state *state)
{
    int ret;

    ret = skb_copy_datagram_msg(skb, UNIXCB(skb).consumed + skip,
                    state->msg, chunk);
    return ret ?: chunk;
}

接着看unix_detach_fds。

void unix_detach_fds(struct scm_cookie *scm, struct sk_buff *skb)
{
    int i;
    // 写到scm中
    scm->fp = UNIXCB(skb).fp;
    UNIXCB(skb).fp = NULL;
}

unix_detach_fds把文件描述符信息写到scm。最后调用scm_recv处理文件描述符。

static __inline__ void scm_recv(struct socket *sock, struct msghdr *msg,
                struct scm_cookie *scm, int flags)
{
    scm_detach_fds(msg, scm);
}

void scm_detach_fds(struct msghdr *msg, struct scm_cookie *scm)
{
    int fdmax = min_t(int, scm_max_fds(msg), scm->fp->count);
    int i;

    for (i = 0; i < fdmax; i++) {
        err = receive_fd_user(scm->fp->fp[i], cmsg_data + i, o_flags);
        if (err < 0)
            break;
    }

}

scm_detach_fds中调用了receive_fd_user处理一个文件描述符。其中第一个入参scm->fp->fp[i]是file结构体,即需传递到文件描述符对应的file。我们看看怎么处理的。

static inline int receive_fd_user(struct file *file, int __user *ufd,
                  unsigned int o_flags)
{
    return __receive_fd(-1, file, ufd, o_flags);
}

int __receive_fd(int fd, struct file *file, int __user *ufd, unsigned int o_flags)
{
    int new_fd;
    // fd是-1,申请一个新的
    if (fd < 0) {
        new_fd = get_unused_fd_flags(o_flags);
    } else {
        new_fd = fd;
    }

    if (fd < 0) {
        fd_install(new_fd, get_file(file));
    } else {
        // ...
    }
    return new_fd;
}

我们看到首先申请了当前进程的一个新的文件描述符。然后调用fd_install关联到file。

void fd_install(unsigned int fd, struct file *file)
{   
    // current->files是当前进程打开的文件描述符列表
    __fd_install(current->files, fd, file);
}

void __fd_install(struct files_struct *files, unsigned int fd,
        struct file *file)
{
    struct fdtable *fdt;
    // 拿到管理文件描述符的数据结构
    fdt = rcu_dereference_sched(files->fdt);
    // 给某个元素赋值指向file
    rcu_assign_pointer(fdt->fd[fd], file);
}

最后形成下图所示的架构。

后记,我们看到文件描述符传递的核心就是在发送的数据中记录要传递的文件描述符对应的file结构体,然后发送做好标记,接着接收的过程中,重新建立新的fd到传递的file的关联关系。

应用

传送文件描述符是高并发网络服务编程的一种常见实现方式。Nebula 高性能通用网络框架即采用了UNIX域套接字传递文件描述符设计和实现。本文详细说明一下传送文件描述符的应用。

TCP服务器程序设计范式

开发一个服务器程序,有较多的的程序设计范式可供选择,不同范式有其自身的特点和实用范围,明了不同范式的特性有助于我们服务器程序的开发。常见的TCP服务器程序设计范式有以下几种:

  • 迭代服务器
  • 并发服务器,每个客户请求fork一个子进程
  • 预先派生子进程,每个子进程无保护的调用accept
  • 预先派生子进程,使用文件上锁保护accept
  • 预先派生子进程,父进程向子进程传递套接字描述符
  • 并发服务器,每个客户端请求创建一个线程
  • 预先创建线程服务器,使用互斥锁上锁保护accept
  • 预先创建线程服务器,由主线程调用accept

当系统负载比较轻时,传统的并发服务器模型就足够了。相对于传统的每个客户一次fork设计,预先创建一个进程池或者线程池可以减少进程控制CPU时间,大约可以减少10倍以上

某些实现允许多个子进程或者线程阻塞在accept上,然而在另一些实现中,我们必须使用文件锁、线程互斥锁或者其他类型的锁来确保每次只有一个子进程或者线程在accept

一般来讲,所有子进程或者线程都调用accept要比父进程或者主线程调用accept后将描述符传递给子进程或者线程来的快而且简单

Nebula 为什么采用传递文件描述符方式?

Nebula框架是预先创建多进程,由Manager主进程accept后传递文件描述符到Worker子进程的服务模型(Nebula进程模型)。为什么不采用像nginx那样多线程由子线程使用互斥锁上锁保护accept的服务模型?而且这种服务模型的实现比传递文件描述符来得还简单一些。

Nebula框架采用无锁设计,进程之前完全不共享数据,不存在需要互斥访问的地方。没错,会存在数据多副本问题,但这些多副本往往只是些配置数据,占用不了太大内存,与加锁解锁带来的代码复杂度及锁开销相比这点内存代价更划算也更简单。

同一个Nebula服务的工程进程间不相互通信,采用进程和线程并无太大区别,之所以采用进程而不是线程的最重要考虑是Nebula是出于稳定性和容错性考虑。Nebula是通用框架,完全业务无关,业务都是通过动态加载的方式或者通过将Nebula链接进业务Server的方式来实现。Nebula框架无法预知业务代码的质量,但是可以保障服务可用性

决定Nebula采用传递文件描述符方式的最重要一点是:Nebula定位是高性能分布式服务集群解决方案的基础通信框架,其设计更多要为构建分布式服务集群而考虑。集群不同服务节点之间通过TCP通信,而所有逻辑都有Worker进程负责,这意味着节点之间通信需要指定到Worker进程,而如果采用子进程竞争accept的方式无法保障指定的子进程获得资源,那么第一个通信数据包将会路由错误。采用传递文件描述符方式可以很完美地解决这个问题,而且传递文件描述符也非常高效。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值