この記事は、「Linux ネットワークの徹底理解」の学習メモです。使用した Linux ソース コードのバージョンは 3.10 です。ネットワーク カード ドライバーは、デフォルトで Intel の igb ネットワーク カード ドライバーを使用します。
Linux ソース コードをオンラインで読む: https://elixir.bootlin.com/linux/v3.10/source
2. カーネルとユーザープロセスの連携方法(1)
1) ソケットの直接作成
開発者の観点から見ると、socket 関数を呼び出すとソケットが作成されます。
ソケット関数呼び出しが実行された後、ユーザー レベルでは整数ハンドルが返されたことがわかりますが、実際にはカーネルは内部で一連のソケット関連のカーネル オブジェクトを作成します。それらの相互の関係を次の図に示します。
// net/socket.c
SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
...
retval = sock_create(family, type, protocol, &sock);
...
}
sock_create はソケットを作成するための主な場所であり、そこで sock_create が呼び出されます。__sock_create
// net/socket.c
int __sock_create(struct net *net, int family, int type, int protocol,
struct socket **res, int kern)
{
int err;
struct socket *sock;
const struct net_proto_family *pf;
...
// 分配socket对象
sock = sock_alloc();
...
// 获得每个协议族的操作表
pf = rcu_dereference(net_families[family]);
...
// 调用指定协议族的创建函数,对于AF_INET对应的是inet_create
err = pf->create(net, sock, protocol, kern);
...
}
ここでは__sock_create
、まず sock_alloc を呼び出して struct ソケット カーネル オブジェクトを割り当て、次にプロトコル ファミリの操作関数テーブルを取得し、その create メソッドを呼び出します。AF_INET プロトコル ファミリの場合、inet_create メソッドが実行されます。
// net/ipv4/af_inet.c
static struct inet_protosw inetsw_array[] =
{
{
.type = SOCK_STREAM,
.protocol = IPPROTO_TCP,
.prot = &tcp_prot,
.ops = &inet_stream_ops,
.no_check = 0,
.flags = INET_PROTOSW_PERMANENT |
INET_PROTOSW_ICSK,
},
...
};
static int inet_create(struct net *net, struct socket *sock, int protocol,
int kern)
{
struct sock *sk;
struct inet_protosw *answer;
struct inet_sock *inet;
struct proto *answer_prot;
...
list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
err = 0;
if (protocol == answer->protocol) {
if (protocol != IPPROTO_IP)
break;
} else {
if (IPPROTO_IP == protocol) {
protocol = answer->protocol;
break;
}
if (IPPROTO_IP == answer->protocol)
break;
}
err = -EPROTONOSUPPORT;
}
...
// 将inet_stream_ops赋到socket->ops上
sock->ops = answer->ops;
// 获取tcp_prot
answer_prot = answer->prot;
...
// 分配sock对象,并把tcp_prot赋到sock->sk_prot上
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot);
...
// 对sock对象进行初始化
sock_init_data(sock, sk);
...
}
次の図に示すように、inet_create で、SOCK_STREAM タイプに従って TCP に定義された操作メソッド実装コレクション inet_stream_ops および tcp_prot を検索し、それぞれソケット->ops および sock->sk_prot に設定します。
さらに下に sock_init_data があります。このメソッドでは、次の図に示すように、ソケット内の sk_data_ready 関数ポインタが初期化され、デフォルトの sock_def_readable に設定されます。
// net/core/sock.c
void sock_init_data(struct socket *sock, struct sock *sk)
{
...
sk->sk_data_ready = sock_def_readable;
sk->sk_write_space = sock_def_write_space;
sk->sk_error_report = sock_def_error_report;
...
}
ソフト割り込みでデータ パケットが受信されると、ソケットで待機しているプロセスは、 sk_data_ready 関数ポインター (実際には sock_def_readable() に設定されます) を呼び出すことによって目覚めます。これについては、後で「ソフト割り込みモジュール」について説明するときに説明します。
この時点で、tcp オブジェクト、正確には AF_INET プロトコル ファミリの下の SOCKET_STREAM オブジェクトが作成されています。これにより、ソケット システム コールのオーバーヘッドが発生します。
2) カーネルとユーザープロセス間の連携の遮断方法
ブロッキング IO モデル:
ユーザー スレッドが IO リクエストを発行すると、カーネルはデータの準備ができているかどうかを確認します。準備ができていない場合は、データの準備ができるまで待機し、ユーザー スレッドはブロック状態になり、ユーザースレッドは CPU を引き渡します。データの準備が完了すると、カーネルはデータをユーザー スレッドにコピーし、結果をユーザー スレッドに返し、ユーザー スレッドはブロック状態を解放します。
同期ブロッキング IO モデルでは、ユーザー プロセスはまずソケットを作成する命令を開始し、次にカーネル状態に切り替えてカーネル オブジェクトの初期化を完了します。次に、Linux はハード割り込みと ksoftirqd スレッドを使用してデータ パケットを処理します。ksoftirqd スレッドが処理されると、関連するユーザー プロセスに通知されます。
ユーザー プロセスがソケットを作成してから、ネットワーク パケットがネットワーク カードに到着してユーザー プロセスによって受信されるまで、同期ブロッキング IO の全体的なプロセスは次の図に示すようになります。
1) メッセージの受信を待っています
clibライブラリのrecv関数はrecvformシステムコールを実行します。システムコールに入った後、ユーザープロセスはカーネル状態に入り、一連のカーネルプロトコル層関数を実行した後、ソケットオブジェクトの受信キューにデータがあるかどうかを確認し、データがなければ、対応する待機キューに自身を追加します。ソケットに。最後に、CPU が解放され、オペレーティング システムが次に実行可能なプロセスを選択します。プロセス全体を次の図に示します。
次に、ソースコードに基づいて、より具体的な詳細を見てみましょう。注目すべき重要な点は、recvfrom が最終的にどのように自身のプロセスをブロックするかです。
// net/socket.c
SYSCALL_DEFINE6(recvfrom, int, fd, void __user *, ubuf, size_t, size,
unsigned int, flags, struct sockaddr __user *, addr,
int __user *, addr_len)
{
struct socket *sock;
...
// 根据用户传入的fd找到socket对象
sock = sockfd_lookup_light(fd, &err, &fput_needed);
...
err = sock_recvmsg(sock, &msg, size, flags);
...
}
次の呼び出しシーケンスは次のとおりです: sock_recvmsg => __sock_recvmsg
=>__sock_recvmsg_nosec
// net/socket.c
static inline int __sock_recvmsg_nosec(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t size, int flags)
{
...
return sock->ops->recvmsg(iocb, sock, msg, size, flags);
}
ソケット オブジェクト ops で recvmsg を呼び出します。recvmsg は inet_recvmsg メソッドを指します。
// net/ipv4/af_inet.c
int inet_recvmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg,
size_t size, int flags)
{
...
err = sk->sk_prot->recvmsg(iocb, sk, msg, size, flags & MSG_DONTWAIT,
flags & ~MSG_DONTWAIT, &addr_len);
...
}
ここで、別の関数ポインタが登場します。今回は、ソケット オブジェクトの sk_prot で、recvmsg メソッドを呼び出します。recvmsg メソッドは、tcp_recvmsg メソッドに対応します。
// net/ipv4/tcp.c
int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t len, int nonblock, int flags, int *addr_len)
{
...
int copied = 0;
...
do {
...
// 遍历接收队列接收数据
skb_queue_walk(&sk->sk_receive_queue, skb) {
...
}
...
if (copied >= target) {
release_sock(sk);
lock_sock(sk);
} else // 没有收到足够数据,启用sk_wait_data阻塞当前进程
sk_wait_data(sk, &timeo);
...
} while (len > 0);
...
}
次の図に示すように、skb_queue_walk は sock オブジェクトの下の受信キューにアクセスします。
データを受信しないか、十分なデータを受信していない場合は、sk_wait_data を呼び出して現在のプロセスをブロックします。
// net/core/sock.c
int sk_wait_data(struct sock *sk, long *timeo)
{
int rc;
// 当前进程(current)关联到所定义的等待队列项上
DEFINE_WAIT(wait);
// 调用sk_sleep获取sock对象下的wait
// 并准备挂起,将当前进程设置为可打断(INTERRUPTIBLE)
prepare_to_wait(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
set_bit(SOCK_ASYNC_WAITDATA, &sk->sk_socket->flags);
// 通过调用schedule_timeout让出CPU,然后进行睡眠
rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));
...
}
以下の図に示すように、sk_wait_data が現在のプロセスをどのようにブロックするかを詳しく見てみましょう。
まず、DEFINE_WAIT マクロの下で、待機キュー項目の wait が定義されます。この新しい待機キュー項目では、コールバック関数 autoremove_wake_function が登録され、現在のプロセス記述子 current がその.private
メンバーに関連付けられます。
// include/linux/wait.h
#define DEFINE_WAIT_FUNC(name, function) \
wait_queue_t name = {
\
.private = current, \
.func = function, \
.task_list = LIST_HEAD_INIT((name).task_list), \
}
#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)
次に、sk_wait_data の sk_sleep を呼び出して、ソケット オブジェクトの下の待機キュー リストのヘッド wait_queue_head_t を取得します。sk_sleep のソースコードは次のとおりです。
// include/net/sock.h
static inline wait_queue_head_t *sk_sleep(struct sock *sk)
{
BUILD_BUG_ON(offsetof(struct socket_wq, wait) != 0);
return &rcu_dereference_raw(sk->sk_wq)->wait;
}
次に、prepare_to_wait を呼び出して、新しく定義した待機キュー項目 wait を sock オブジェクトの待機キューに挿入します。
// kernel/wait.c
void
prepare_to_wait(wait_queue_head_t *q, wait_queue_t *wait, int state)
{
unsigned long flags;
wait->flags &= ~WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
if (list_empty(&wait->task_list))
__add_wait_queue(q, wait);
set_current_state(state);
spin_unlock_irqrestore(&q->lock, flags);
}
このようにして、カーネルがデータを収集して Ready イベントを生成すると、ソケット待機キュー上の待機中のアイテムを検索し、ソケット Ready イベントを待機しているコールバック関数とプロセスを見つけることができます。
最後に、sk_wait_event を呼び出して CPU を解放すると、プロセスはスリープ状態になり、プロセス コンテキスト スイッチのオーバーヘッドが生成されます。このオーバーヘッドは高価であり、数マイクロ秒程度の CPU 時間を要します。
2)ソフト割り込みモジュール
前回の記事では、ネットワーク パケットがネットワーク カードに到着した後、どのようにネットワーク カードで受信され、最終的に処理のためにソフト割り込みに渡されるかについて説明しましたが、ここでは、TCP プロトコルの受信関数 tcp_v4_rcv から直接開始して、全体の受信プロセスを次の図に示します。
ソフト割り込み (つまり、Linux の ksoftirqd スレッド) でデータを受信した後、それが TCP パケットであることが判明すると、tcp_v4_rcv 関数が実行されます。次に、ESTABLISH 状態のデータパケットであれば、最終的にデータが分解されて対応するソケットの受信キューに置かれ、sk_data_ready が呼び出され、ユーザープロセスが起動されます。
// net/ipv4/tcp_ipv4.c
int tcp_v4_rcv(struct sk_buff *skb)
{
...
// 获取tcp header
th = tcp_hdr(skb);
// 获取ip header
iph = ip_hdr(skb);
...
// 根据数据包header中的IP、端口信息查找到对应的socket
sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
...
// socket未被用户锁定
if (!sock_owned_by_user(sk)) {
...
{
if (!tcp_prequeue(sk, skb))
ret = tcp_v4_do_rcv(sk, skb);
}
}
...
}
tcp_v4_rcv では、まず、受信したネットワーク パケットのヘッダーにある送信元と送信先の情報に基づいて、ローカル マシン上の対応するソケットをクエリします。見つかったら、tcp_v4_do_rcv 関数を呼び出します。
// net/ipv4/tcp_ipv4.c
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
...
if (sk->sk_state == TCP_ESTABLISHED) {
...
// 执行连接状态下的数据处理
if (tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len)) {
rsk = sk;
goto reset;
}
return 0;
}
// 其他非ESTABLISH状态的数据包处理
...
}
ESTABLISH 状態でパケットを処理していると仮定して、処理のために tcp_rcv_steady 関数に入ります。
// net/ipv4/tcp_input.c
int tcp_rcv_established(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th, unsigned int len)
{
...
// 接收数据放到队列中
eaten = tcp_queue_rcv(sk, skb, tcp_header_len,
&fragstolen);
...
// 数据准备好,唤醒socket上阻塞掉的进程
sk->sk_data_ready(sk, 0);
...
}
tcp_rcv_確立では、次の図に示すように、tcp_queue_rcv 関数を呼び出すことにより、受信データがソケットの受信キューに配置されます。
関数 tcp_queue_rcv のソースコードは次のとおりです。
// net/ipv4/tcp_input.c
static int __must_check tcp_queue_rcv(struct sock *sk, struct sk_buff *skb, int hdrlen,
bool *fragstolen)
{
...
// 把接收到的数据放到socket的接收队列的尾部
if (!eaten) {
__skb_queue_tail(&sk->sk_receive_queue, skb);
skb_set_owner_r(skb, sk);
}
return eaten;
}
tcp_queue_rcv を呼び出して受信した後、sk_data_ready を呼び出して、ソケットで待機しているユーザー プロセスを起動します。これも関数ポインタです。「ソケットの直接作成」の前のセクションでは、ソケットの作成プロセスで実行された sock_init_data 関数が sk_data_ready ポインタを sock_def_readable 関数に設定していると述べました。これはデフォルトのデータ準備ハンドラー関数です
// net/core/sock.c
static void sock_def_readable(struct sock *sk, int len)
{
struct socket_wq *wq;
rcu_read_lock();
wq = rcu_dereference(sk->sk_wq);
// 有进程在此socket的等待队列
if (wq_has_sleeper(wq))
// 唤醒等待队列上的进程
wake_up_interruptible_sync_poll(&wq->wait, POLLIN | POLLPRI |
POLLRDNORM | POLLRDBAND);
sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
rcu_read_unlock();
}
sock_def_readable では、sock->sk_wq が再度アクセスされるまで待機します。前の「メッセージの受信待機」部分で recvform を呼び出すと、実行プロセスの終了時に、DEFINE_WAIT(wait)
現在のプロセスに関連付けられた待機キューが sock->sk_wq の待機に追加されます。
次のステップでは、次の図に示すように、wake_up_interruptible_sync_poll を呼び出して、ソケット上でデータの待機中にブロックされているプロセスをウェイクアップします。
// include/linux/wait.h
#define wake_up_interruptible_sync_poll(x, m) \
__wake_up_sync_key((x), TASK_INTERRUPTIBLE, 1, (void *) (m))
// kernel/sched/core.c
void __wake_up_sync_key(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;
int wake_flags = WF_SYNC;
if (unlikely(!q))
return;
if (unlikely(!nr_exclusive))
wake_flags = 0;
spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, wake_flags, key);
spin_unlock_irqrestore(&q->lock, flags);
}
__wake_up_common
覚醒を達成します。この関数呼び出しに渡されるパラメータ nr_exclusive は 1 です。これは、同じソケット上で複数のプロセスがブロックされている場合でも、起動されるプロセスは 1 つだけであることを意味します。その目的は、すべてのプロセスを起動するのではなく、パニックを回避することです。
// kernel/sched/core.c
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
unsigned flags = curr->flags;
if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
__wake_up_common
で待機中のキュー項目 curr を見つけて、その curr->func を呼び出します。recv 関数の前の部分「メッセージの受信を待機中」が実行されるとき、DEFINE_WAIT() を使用して待機キュー項目を定義すると、カーネルは curr->func を autoremove_wake_function に設定します。
// kernel/wait.c
int autoremove_wake_function(wait_queue_t *wait, unsigned mode, int sync, void *key)
{
int ret = default_wake_function(wait, mode, sync, key);
if (ret)
list_del_init(&wait->task_list);
return ret;
}
autoremove_wake_function では、default_wake_function が呼び出されます。
// kernel/sched/core.c
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags,
void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
try_to_wake_up の呼び出し時に渡される task_struct は curr->private であり、待機によりブロックされたプロセス項目です。この関数が実行されると、ソケットで待機中にブロックされたプロセスが実行可能キューにプッシュされ、プロセス コンテキスト スイッチのオーバーヘッドが生成されます。
3) 同期ブロッキングの概要
同期ブロッキング モードでネットワーク パケットを受信するプロセス全体は、次の 2 つの部分に分かれています。
- 最初の部分は独自のコードが配置されるプロセスで、呼び出したsocket()関数はカーネル状態に入り、必要なカーネルオブジェクトを作成します。recv() 関数は、カーネル状態に入った後に受信キューをチェックし、処理するデータがない場合に現在のプロセスをブロックして CPU を放棄する役割を果たします。
- 2 番目の部分は、ハード割り込みとソフト割り込み (システム スレッド ksoftirqd) です。これらのコンポーネントでは、パケットが処理された後、ソケットの受信キューに配置されます。次に、ソケット カーネル オブジェクトに従って、待機キュー内で待機しているためにブロックされているプロセスを見つけて、ウェイクアップします。
同期ブロッキングの全体的なプロセスを次の図に示します。
次の図に示すように、ソケット上のデータを待機するためにプロセスが CPU から切り離されるたびに、別のプロセスに置き換えられます。データの準備ができると、スリープ状態のプロセスが再び起動され、合計 2 つのプロセス コンテキスト切り替えのオーバーヘッドが発生します。
推奨読書:
Linux の 5 つの I/O モデル: Linux の 5 つの I/O モデルを徹底的に理解します。