新Linuxカーネル解読室 - ソケットインターフェース(データ構造と概要編)

「Linuxカーネル2.6解読室」(以降、旧版)出版後、Linuxには多くの機能が追加され、エンタープライズ領域をはじめとする様々な場所で使われるようになりました。 それに伴いコードが肥大かつ複雑化し、多くのエンジニアにとって解読不能なブラックボックスとなっています。 世界中のトップエンジニア達の傑作であるLinuxカーネルにメスを入れ、ブラックボックスをこじ開けて、時に好奇心の赴くままにカーネルの世界を解読する「新Linuxカーネル解読室」プロジェクト。

本稿では、旧版第21章で解説されていたソケットインターフェースについて、カーネルv6.8のコードをベースに主にデータ構造を中心に解説します。

執筆者 : 須田 哲志、稲葉 貴昭

※ 「新Linuxカーネル解読室」連載記事一覧はこちら


はじめに

アプリケーションがネットワークを利用する場合、socket(2)システムコールでソケットを作成し、connect(2)/bind(2)/listen(2)/accept(2)といった専用のシステムコールを使うことで他のアプリケーションと通信することができます。
ところでsocket(2)によって作成されたソケットはアプリケーションから見ると、ソケットディスクリプタとして取得されます。 また、ファイル操作のためのread(2)/write(2)といったシステムコールを、このソケットディスクリプタに対して使用することもできます。 すなわちソケットディスクリプタはファイルの側面も持っているということです。 それではソケットとは一体何なのでしょうか。
ソケットの実体となるデータ構造はsocket構造体とsock構造体になります。 そしてこれらはsockfsという疑似ファイルシステムとして抽象化/実装されており、これによりファイルとしての操作を実現しています。 本稿ではプロトコルごとに異なる処理をソケットがどのように抽象化しているのか、またソケットがなぜファイルとして操作できるのかについて、データ構造を中心に見ていきたいと思います。

ソケットの実体と概要

前述のとおりソケットの実体となるデータ構造はsocket構造体とsock構造体になります。 そして、これらはファイル操作のためのシステムコールから利用できるよう、何らかの形でfile構造体等と関連付けられています。 実際にsocket構造体、sock構造体のソースコードを見ると以下のように互いにポインタで関連付けられていることがわかります。*1

図1. socket構造体-sock構造体-file構造体の関係

ソケットはsocket構造体とsock構造体の2つに分離されていますが、socket構造体はBSD由来のsocket APIのインターフェースそのものを表現しており、sock構造体はネットワークレイヤーのインターフェースを表しています。 そのため、IPv4/IPv6などプロトコルによってsock構造体の初期化処理や登録するコールバック関数は異なってきます。 また、socket構造体がfile構造体へのポインタを持っていることから、socket APIがファイルと関連づけられていることがわかると思います。

ソケット操作関数の実装

通信のハンドリングの仕方はプロトコルごとに異なりますが、ソケットを使うことでアプリケーションはそれらをほとんど意識せずに通信を行うことができます。これはプロトコルごとの差異をコールバック関数によって抽象化することで実現されています。具体例としてソケット操作関数(システムコール)であるsendmsg(2)/recvmsg(2)を例にソケットがどのようにプロトコルごとの差異を抽象化しているのか見てみましょう。

まずsendmsg(2)のソースコードを辿っていくと以下のように関数が呼び出されていることがわかります。(主要な関数しか取り上げていません)

sendmsg()
  └── __sys_sendmsg()
        ├── sockfd_lookup_light()
        └── ___sys_sendmsg()
              └── ____sys_sendmsg()
                    └── sock_sendmsg_nosec()
                          └── sock->ops->sendmsg

同様にしてrecvmsg(2)のソースコードも辿ってみると以下のように関数が呼び出されていることがわかります。

recvmsg()
  └── __sys_recvmsg()
        ├── sockfd_lookup_light()
        └── ___sys_recvmsg()
              └── ____sys_recvmsg()
                    └── sock_recvmsg()
                          └── sock_recvmsg_nosec()
                                └── sock->ops->recvmsg

どちらのシステムコールも非常に似た関数の呼び出し構造になっています。最初にsockfd_lookup_light()でソケットディスクリプタ(ファイルディスクリプタ)に対応するsocket構造体を取得し、最終的にsock->ops内のコールバック関数を呼んでいます。 このsock->opsは、socket構造体が参照しているproto_ops構造体であり以下のような構造になっています。
※変数名がsockとなっていますが、sock構造体ではなくsocket構造体を指しています。非常に紛らわしいですね。

/include/linux/net.h

struct proto_ops {
    int        family;
    struct module  *owner;
    int        (*release)   (struct socket *sock);
    int        (*bind)      (struct socket *sock,
                      struct sockaddr *myaddr,
                      int sockaddr_len);
    int        (*connect)   (struct socket *sock,
                      struct sockaddr *vaddr,
                      int sockaddr_len, int flags);
    int        (*socketpair)(struct socket *sock1,
                      struct socket *sock2);
    int        (*accept)    (struct socket *sock,
                      struct socket *newsock, int flags, bool kern);
    int        (*getname)   (struct socket *sock,
                      struct sockaddr *addr,
                      int peer);
    __poll_t    (*poll)       (struct file *file, struct socket *sock,
                      struct poll_table_struct *wait);
    int        (*ioctl)     (struct socket *sock, unsigned int cmd,
                      unsigned long arg);
#ifdef CONFIG_COMPAT
    int        (*compat_ioctl) (struct socket *sock, unsigned int cmd,
                      unsigned long arg);
#endif
    int        (*gettstamp) (struct socket *sock, void __user *userstamp,
                      bool timeval, bool time32);
    int        (*listen)    (struct socket *sock, int len);
    int        (*shutdown)  (struct socket *sock, int flags);
    int        (*setsockopt)(struct socket *sock, int level,
                      int optname, sockptr_t optval,
                      unsigned int optlen);
    int        (*getsockopt)(struct socket *sock, int level,
                      int optname, char __user *optval, int __user *optlen);
    void       (*show_fdinfo)(struct seq_file *m, struct socket *sock);
    int        (*sendmsg)   (struct socket *sock, struct msghdr *m,
                      size_t total_len);
    /* Notes for implementing recvmsg:
    * ===============================
    * msg->msg_namelen should get updated by the recvmsg handlers
    * iff msg_name != NULL. It is by default 0 to prevent
    * returning uninitialized memory to user space.  The recvfrom
    * handlers can assume that msg.msg_name is either NULL or has
    * a minimum size of sizeof(struct sockaddr_storage).
    */
    int        (*recvmsg)   (struct socket *sock, struct msghdr *m,
                      size_t total_len, int flags);
    int        (*mmap)      (struct file *file, struct socket *sock,
                      struct vm_area_struct * vma);
    ssize_t    (*splice_read)(struct socket *sock,  loff_t *ppos,
                       struct pipe_inode_info *pipe, size_t len, unsigned int flags);
    void       (*splice_eof)(struct socket *sock);
    int        (*set_peek_off)(struct sock *sk, int val);
    int        (*peek_len)(struct socket *sock);

    /* The following functions are called internally by kernel with
    * sock lock already held.
    */
    int        (*read_sock)(struct sock *sk, read_descriptor_t *desc,
                     sk_read_actor_t recv_actor);
    /* This is different from read_sock(), it reads an entire skb at a time. */
    int        (*read_skb)(struct sock *sk, skb_read_actor_t recv_actor);
    int        (*sendmsg_locked)(struct sock *sk, struct msghdr *msg,
                      size_t size);
    int        (*set_rcvlowat)(struct sock *sk, int val);
};

メンバ変数のほとんどがコールバック関数になっており、またソケット操作のためのシステムコール名と関連していそうなものが多いことがわかります。 ここまでを一度図に表すと以下のような構成になっていることがわかります。*2

図2. proto_ops構造体によるソケット操作処理の集約

ソケット操作用のシステムコールを発行すると多くは、このproto_ops構造体内のコールバック関数に処理が移ります。 Linuxカーネルではソケット生成時にsocket構造体が参照するporot_ops構造体をプロトコルに合わせて登録することでプロトコルごとの処理の差を吸収しています。

さらにここでproto_ops構造体のコールバック関数を少し追ってみましょう。 socket構造体が参照するproto_ops構造体はプロトコルによって異なるため、ここでは一例としてproto_ops構造体にinet_stream_opsが指定された場合を例に追ってみます。 inet_stream_opsはsocket(PF_INET, SOCK_STREAM, IPPROTO_TCP)でソケットが生成された場合に登録されます。

/net/ipv4/af_inet.c

const struct proto_ops inet_stream_ops = {
    .family        = PF_INET,
    .owner         = THIS_MODULE,
    .release       = inet_release,
    .bind          = inet_bind,
    .connect       = inet_stream_connect,
    .socketpair    = sock_no_socketpair,
    .accept        = inet_accept,
    .getname       = inet_getname,
    .poll          = tcp_poll,
    .ioctl         = inet_ioctl,
    .gettstamp     = sock_gettstamp,
    .listen        = inet_listen,
    .shutdown      = inet_shutdown,
    .setsockopt    = sock_common_setsockopt,
    .getsockopt    = sock_common_getsockopt,
    .sendmsg       = inet_sendmsg,
    .recvmsg       = inet_recvmsg,
#ifdef CONFIG_MMU
    .mmap          = tcp_mmap,
#endif
    .splice_eof    = inet_splice_eof,
    .splice_read       = tcp_splice_read,
    .read_sock     = tcp_read_sock,
    .read_skb      = tcp_read_skb,
    .sendmsg_locked    = tcp_sendmsg_locked,
    .peek_len      = tcp_peek_len,
#ifdef CONFIG_COMPAT
    .compat_ioctl      = inet_compat_ioctl,
#endif
    .set_rcvlowat      = tcp_set_rcvlowat,
};
EXPORT_SYMBOL(inet_stream_ops);

先ほどの例に合わせて、.sendmsg.recvmsgに登録されている関数inet_sendmsginet_recvmsgを見てみましょう。

/net/ipv4/af_inet.c

int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t size)
{
    struct sock *sk = sock->sk;

    if (unlikely(inet_send_prepare(sk)))
        return -EAGAIN;

    return INDIRECT_CALL_2(sk->sk_prot->sendmsg, tcp_sendmsg, udp_sendmsg, sk, msg, size);
}
EXPORT_SYMBOL(inet_sendmsg);
...

int inet_recvmsg(struct socket *sock, struct msghdr *msg, size_t size,
         int flags)
{
    struct sock *sk = sock->sk;
    int addr_len = 0;
    int err;

    if (likely(!(flags & MSG_ERRQUEUE)))
        sock_rps_record_flow(sk);

    err = INDIRECT_CALL_2(sk->sk_prot->recvmsg, tcp_recvmsg, udp_recvmsg,
                  sk, msg, size, flags, &addr_len);
    if (err >= 0)
        msg->msg_namelen = addr_len;
    return err;
}
EXPORT_SYMBOL(inet_recvmsg);

どちらの関数も最終的にsk->sk_prot内のコールバック関数を呼び出しており、処理の実体はこれらのコールバック関数であることがわかります。 (どちらもINDIRECT_CALL_2マクロで包まれていますが、結局は第一引数に登録されているsk->sk_prot内のコールバック関数が呼ばれることと同義です。) sk->sk_protはsock構造体の内部(sock_common構造体内)で参照しているproto構造体であり、以下の関係にあります。

図3. proto構造体によるプロトコル依存処理の抽象化

socket構造体でプロトコルごとの差異を吸収するために各種処理をproto_ops構造体でコールバック関数として抽象化していたのと同様に、sock構造体でもプロトコルごとの差異を吸収するために処理や状態をproto構造体を使って抽象化しています。 今回の例のようにproto_ops構造体の処理の実体がsock構造体のproto構造体側にあることは、socket構造体がソケットインターフェースそのものを表現し、sock構造体はネットワークレイヤーのインターフェースを表現していることから、よりプロトコルに依存した処理をsock構造体側で実装していると考えられます。
※ただし、全てのプロトコルの実装が今回と同じ実装の構造になっているわけではありません。

ファイル操作関数によるソケット操作の実装

socket(2)システムコールによって取得されるソケットディスクリプタはファイル操作のためのシステムコールを使って操作することもできます。 ここではファイル操作用のシステムコールであるread(2)/write(2)を例にソケットがどのようにファイルとして抽象化されているのか見ていきたいと思います。 まずは先と同様にread(2)/write(2)のそれぞれのソースコードを辿ってみましょう。

read(2)のソースコードを辿っていくと以下のように関数が呼び出されていることがわかります。(主要な関数しか取り上げていません)

read()
  └── ksys_read()
        ├── fdget_pos()
        └── vfs_read()
              └── new_sync_read()
                    └── call_read_iter()
                          └── file->f_op->read_iter

同様にしてwrite(2)のソースコードも辿ってみると以下のように関数が呼び出されていることがわかります。

write()
  └── ksys_write()
        ├── fdget_pos()
        └── vfs_write()
              └── new_sync_write()
                    └── call_write_iter()
                          └── file->f_op->write_iter

どちらのシステムコールも非常に似た関数の呼び出し構造になっていることがわかります。 最初にfdget_pos()でファイルディスクリプタに対応したfile構造体を取得し、最終的にfile->f_op内のコールバック関数を実行しています。 すなわち、file->f_op内のコールバック関数が処理の実体ということになります。 f_opはfile構造体が参照しているfile_operations構造体で以下の構造をとります。

/include/linux/fs.h

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
    int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
            unsigned int flags);
    int (*iterate_shared) (struct file *, struct dir_context *);
    __poll_t (*poll) (struct file *, struct poll_table_struct *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    unsigned long mmap_supported_flags;
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    int (*release) (struct inode *, struct file *);
    int (*fsync) (struct file *, loff_t, loff_t, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
    int (*check_flags)(int);
    int (*flock) (struct file *, int, struct file_lock *);
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
    void (*splice_eof)(struct file *file);
    int (*setlease)(struct file *, int, struct file_lock **, void **);
    long (*fallocate)(struct file *file, int mode, loff_t offset,
              loff_t len);
    void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
    unsigned (*mmap_capabilities)(struct file *);
#endif
    ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
            loff_t, size_t, unsigned int);
    loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
                   struct file *file_out, loff_t pos_out,
                   loff_t len, unsigned int remap_flags);
    int (*fadvise)(struct file *, loff_t, loff_t, int);
    int (*uring_cmd)(struct io_uring_cmd *ioucmd, unsigned int issue_flags);
    int (*uring_cmd_iopoll)(struct io_uring_cmd *, struct io_comp_batch *,
                unsigned int poll_flags);
} __randomize_layout;

構造体の名称からもわかる通り、ファイル操作の処理はこのfile_operations構造体に集約/抽象化されています。 すなわち、(socket構造体に紐づく)file構造体が参照するfile_operations構造体(=f_op)にソケット操作のための処理を登録することで、ソケットディスクリプタをファイル操作用のシステムコールで扱えるようになります。 ここまでの関係を一度整理すると以下のようになります。

図4. file_operations構造体によるファイル操作処理の集約

それでは実際にfile_operations構造体のコールバック関数にはどのような関数が登録されているかみてみましょう。 詳細は割愛しますがソケットの場合、ソケット生成時にfile構造体のf_opには以下のsocket_file_opsが登録されます。(詳細は次回解説予定)

/net/socket.c

static const struct file_operations socket_file_ops = {
    .owner =    THIS_MODULE,
    .llseek =   no_llseek,
    .read_iter =    sock_read_iter,
    .write_iter =   sock_write_iter,
    .poll =     sock_poll,
    .unlocked_ioctl = sock_ioctl,
#ifdef CONFIG_COMPAT
    .compat_ioctl = compat_sock_ioctl,
#endif
    .uring_cmd =    io_uring_cmd_sock,
    .mmap =     sock_mmap,
    .release =  sock_close,
    .fasync =   sock_fasync,
    .splice_write = splice_to_socket,
    .splice_read =  sock_splice_read,
    .splice_eof =   sock_splice_eof,
    .show_fdinfo =  sock_show_fdinfo,
};

read(2)/write(2)から呼ばれていたread_iter/write_iterコールバック関数にはそれぞれsock_read_iter()/sock_write_iter()が登録されていることがわかります。 詳細は割愛しますがsock_read_iter()/sock_write_iter()のソースコードを辿ると、それぞれ最終的にproto_ops構造体のrecvmsg/sendmsgコールバック関数を実行していることがわかります。

図5. ファイル操作処理からプロトコル依存処理までの流れ

この仕組みにより、ソケットへの読み書きという観点ではソケット操作用のシステムコールrecvmsg(2)/sendmsg(2)を使った場合でも、ファイル操作用のシステムコールread(2)/write(2)を使った場合でも内部では最終的に同じ処理(proto_ops構造体のコールバック関数)が実行されるようになっています。

すなわち、socket構造体とfile構造体を紐づけ、file_operations構造体の各種コールバック関数に適切なソケット操作処理関数を登録することで、ソケットのファイル抽象化を実現しています。 このようにLinuxカーネルではfile構造体およびfile_operations構造体によりファイル操作を抽象化し、ソケットに対する操作はproto_ops構造体に集約/抽象化することでプロトコルの差異を吸収するという構造になっていると言えます。

図6. file_operations構造体によるファイル操作の抽象化とproto_ops構造体によるソケット操作の抽象化の関係

今回は具体例としてソケットへの読み書きに関する処理を取り上げましたが、他のソケット操作用システムコールやファイル操作用システムコールについても図7に示すように多くが同様の実装になっています。*3

図7. proto_ops構造体による各種ソケット操作の抽象化

次回予告: ソケット生成編

ここまで、socket構造体とsock構造体にクローズアップして、関連するデータ構造とともにソケット操作処理の流れを見てきました。 しかし、socket構造体やsock構造体をとりまくデータ構造は図8のようにもっと多く存在します。 特にsocket構造体とsock構造体が「ソケットの実体である」として見てきましたが、実際にはこれらの構造体はある構造体のメンバ変数として存在しています。
※図8はsocket(PF_INET, SOCK_STREAM, IPPROTO_TCP)により生成されるデータ構造の例です。プロトコルによって一部データ構造は変化します。

図8. socketシステムコールにより生成/関連付けされるデータ構造

次回はsocket(2)システムコールによってこれらの各種データ構造がどのように生成され、関連づけられていくのかを見ていきたいと思います。

*1:クラス図風に変数名: 型名の表記で表現しています。後半の図ではクラス図にはない表現をしだすのでクラス図風としました。

*2:struct proto_opsのように仕切りがあるものは上半分がメンバ変数、下半分がコールバック関数となります。

*3:図7のsetsockopt(2)/getsockopt(2)から伸びる経路はlevelにSOL_SOCKET以外を指定して呼び出した場合になります。SOL_SOCKETを指定する場合は、proto_ops構造体のコールバック関数は経由せずにsock構造体のメンバ変数を直接変更する処理になります。