# 深入理解Linux网络随笔(二):内核是如何与用户进程协作的(上篇:同步I/O阻塞)

深入理解Linux网络随笔(二):内核是如何与用户进程协作的

在网络数据帧通过协议栈处理后,内核需要将数据传递给用户空间的进程进行处理。内核与用户进程的协作主要通过两种方式来唤醒用户进程:

  • 同步阻塞(多用于客户端)(Java(BIO))

  • 在这种模式下,用户进程会被阻塞,直到内核有数据可供处理。客户端进程通常使用这种方式进行等待,直到网络数据到达为止。类似于Java中的**BIO(Blocking
    I/O)**模式,其中I/O操作会阻塞进程,直到操作完成。

  • 多路I/O复用(多用于服务端)(Java (NIO))

  • 在这种模式下,单个线程监控多个网络连接的状态时,避免每个连接都单独占用一个线程,从而提高资源利用效率。类似于Java中的**NIO(Non-blocking
    I/O)**模式,其中I/O操作是非阻塞的,进程可以同时处理多个I/O操作,而不需要等待每个操作完成。

分析内核与用户进程协作相关内容前期准备工作,需要创建并使用 socket

1、socket创建

同步阻塞I/O模型下,用户进程发起一个I/O请求,必须等待内核将数据准备好并返回给用户进程,在等待期间,用户进程处于阻塞状态,不能执行其他任务,只有当数据准备好并返回给用户进程后,用户进程才可以继续执行。适用于简单的 I/O 操作,例如:本地文件的读写操作。在高并发环境下,同步阻塞 I/O 可能会导致性能问题,因为一个阻塞的 I/O 操作会影响其他操作的执行,内核视角看socket总览图如下:

socket创建主要完成如下工作:

1、分配一个新的 struct socket 对象

2、通过协议族找到合适的协议操作并绑定

3、初始化套接字并设置与用户态进行协作的相关回调函数

# 深入理解Linux网络随笔(二):内核是如何与用户进程协作的(上篇:同步I/O阻塞)_第1张图片

#include 
#include 
#include 
#include 
#include 

#define PORT 8080

int main() {
    int sock;
    struct sockaddr_in server_addr;
    char buffer[1024];
    
    // 创建 socket
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock < 0) {
        perror("Socket creation failed");
        return 1;
    }

    // 设置 server 地址
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    // 连接到服务器
    if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("Connection failed");
        return 1;
    }

    printf("Connected to server\n");

    // 发送消息给服务器
    printf("Enter message: ");
    fgets(buffer, sizeof(buffer), stdin);
    write(sock, buffer, strlen(buffer));

    // 接收服务器响应
    int n = read(sock, buffer, sizeof(buffer) - 1);
    buffer[n] = '\0';
    printf("Server response: %s\n", buffer);

    // 关闭 socket
    close(sock);
    return 0;
}

以socket进行同步I/O操作为例。用户编写程序代码时调用socket函数创建套接字,write() 会阻塞直到数据能够成功写入缓冲区并发送到对端,read() 会阻塞直到有数据可以读取,用户仅仅看到的是返回的整数句柄sock,使用该文件描述符进行网络通信。

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
	return __sys_socket(family, type, protocol);
}

用户在应用程序中调用 socket() 时,操作系统将通过系统调用接口进入内核。内核会执行 SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol),并调用内核实现的 __sys_socket() 来处理套接字的创建。

int __sys_socket(int family, int type, int protocol)
{
    ......
	sock = __sys_socket_create(family, type, protocol);
    ......
	return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

函数调用关系__sys_socket-->__sys_socket_create-->socket_create-->__socket_create__sys_socket() 主要调用 __sys_socket_create 来创建套接字。成功时,它会返回一个文件描述符,这个描述符代表了新创建的套接字,并能够让用户进程通过该文件描述符与内核网络协议栈交互。

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;
	sock = sock_alloc();
	if (!sock) {
		net_warn_ratelimited("socket: no more sockets\n");
		return -ENFILE;	/* Not exactly a match, but its the
				   closest posix thing */
	}
    ......
	pf = rcu_dereference(net_families[family]);
	err = -EAFNOSUPPORT;
	if (!pf)
		goto out_release;

	/*
	 * We will call the ->create function, that possibly is in a loadable
	 * module, so we have to bump that loadable module refcnt first.
	 */
	if (!try_module_get(pf->owner))
		goto out_release;

	/* Now protected by module ref count */
	rcu_read_unlock();

	err = pf->create(net, sock, protocol, kern);
	if (err < 0)
		goto out_module_put;

.....
}
EXPORT_SYMBOL(__sock_create)

调用sock_alloc分配struct socket内核对象,并通过协议族获取对应的 create 操作函数(如 inet_create),然后调用协议族的 create 方法来初始化套接字并完成具体的协议栈初始化。

//协议族操作结构体
static const struct net_proto_family inet_family_ops = {
	.family = PF_INET,
	.create = inet_create,
	.owner	= THIS_MODULE,
};

根据用户态传入的AF_INET对应的create函数是inet_create

static int inet_create(struct net *net, struct socket *sock, int protocol, int kern)
{
    struct inet_sock *inet;
    struct sock *sk;
    struct inet_protosw *answer;
    int err;

    // 遍历协议栈,找到与指定协议匹配的操作
    list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
        if (protocol == answer->protocol || protocol == IPPROTO_IP) {
            // 找到匹配的协议
            sock->ops = answer->ops;  // 绑定协议操作
            break;
        }
    }

    // 分配协议栈的套接字对象
    sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer->prot, kern);
    if (!sk) {
        return -ENOMEM;
    }

    // 初始化套接字对象
    sock_init_data(sock, sk);
    return 0;
}

遍历套接字类型sock->type下的所有可用协议栈,inetsw[sock->type]是一个链表,保存着与套接字类型相关的所有协议,根据传入的protocol与当前遍历到的协议 answer->protocol进行协议匹配,匹配成功将匹配到的协议操作函数(answer->ops)绑定到套接字 sock 上,获取tcp_port,通过sk_alloc分配sock对象,将tcp_port赋于sock->sk_portsock_init_data初始化sock对象。

static struct inet_protosw inetsw_array[] =
{
	{
		.type =       SOCK_STREAM,
		.protocol =   IPPROTO_TCP,
		.prot =       &tcp_prot,
		.ops =        &inet_stream_ops,
		.flags =      INET_PROTOSW_PERMANENT |
			      INET_PROTOSW_ICSK,
	},

	{
		.type =       SOCK_DGRAM,
		.protocol =   IPPROTO_UDP,
		.prot =       &udp_prot,
		.ops =        &inet_dgram_ops,
		.flags =      INET_PROTOSW_PERMANENT,
       },

       {
		.type =       SOCK_DGRAM,
		.protocol =   IPPROTO_ICMP,
		.prot =       &ping_prot,
		.ops =        &inet_sockraw_ops,
		.flags =      INET_PROTOSW_REUSE,
       },

       {
	       .type =       SOCK_RAW,
	       .protocol =   IPPROTO_IP,	/* wild card */
	       .prot =       &raw_prot,
	       .ops =        &inet_sockraw_ops,
	       .flags =      INET_PROTOSW_REUSE,
       }
};
void sock_init_data(struct socket *sock, struct sock *sk)
{
    ......
    sk->sk_state_change	=	sock_def_wakeup;//套接字状态变化时的处理函数
	sk->sk_data_ready	=	sock_def_readable;//设置套接字数据可读时的处理函数
	sk->sk_write_space	=	sock_def_write_space;//设置套接字写缓冲区空间可用时的处理函数
	sk->sk_error_report	=	sock_def_error_report;//设置套接字错误报告函数
	sk->sk_destruct		=	sock_def_destruct;//设置套接字销毁时的处理函数
    ......
}

sock_init_data() 用于初始化套接字的 struct sock 对象,包括设置数据准备、写空间、状态变化等回调函数。当数据准备好或者套接字状态发生变化时,内核会调用相应的回调函数来通知用户进程。数据包经过软中断处理后,会通过 sk->sk_data_ready 指向的函数, sock_def_readable唤醒用户进程,通知其数据已经准备好进行读取。开销:socket系统调用

2、同步阻塞I/O模型

同步I/O的核心是通过阻塞进程,等待I/O操作完成后再继续执行,具体完成的内容如下:

1. 阻塞与等待队列管理
  • 触发条件
    当用户程序调用阻塞式I/O函数(如recv())时,若内核缓冲区中无数据/数据不足时,进程会进入阻塞状态。

    recv() → 系统调用 → tcp_recvmsg_locked → sk_wait_data()
    
  • 进程状态切换
    内核将进程状态由TASK_RUNNING(运行态)标记为TASK_INTERRUPTIBLE(可中断睡眠),并从运行队列移出。

    prepare_to_wait_exclusive(sk_sleep(sk), &wait, TASK_INTERRUPTIBLE);
    schedule_timeout(timeo);  // 让出CPU
    
  • 等待队列
    每个I/O资源(如Socket)维护一个等待队列,阻塞的进程通过add_wait_queue加入队列:

    DEFINE_WAIT_FUNC(wait, woken_wake_function);  // 定义等待项
    add_wait_queue(sk_sleep(sk), &wait);          // 加入队列
    

2. 数据到达时的唤醒机制
  • 数据路径
    数据包到达网卡后,触发硬件中断,内核通过软中断(如NET_RX_SOFTIRQ)处理协议栈:

    硬中断 → 网卡收包 → 协议栈处理(IP/TCP) → tcp_v4_rcv → tcp_rcv_established
    
  • 数据入队列
    接收的数据被放入Socket的接收队列:

    __skb_queue_tail(&sk->sk_receive_queue, skb);  // 数据入队
    
  • 唤醒阻塞进程
    内核调用sk->sk_data_ready(如sock_def_readable),唤醒等待队列中的进程:

    wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN);  // 唤醒操作
    ↓
    __wake_up_common → default_wake_function → try_to_wake_up()
    

# 深入理解Linux网络随笔(二):内核是如何与用户进程协作的(上篇:同步I/O阻塞)_第2张图片

接收网络数据包使用recv函数,recv() 默认是阻塞的,意味着如果套接字中没有数据,调用 recv() 会一直阻塞,直到有数据可读取或连接被关闭。可以使用 O_NONBLOCK 标志或 MSG_DONTWAIT 标志让它变成非阻塞行为。

bytes_received = recv(new_sock, buffer, sizeof(buffer) - 1, 0);

同理需要经过系统调用进入内核,调用逻辑为SYSCALL_DEFINE6-->__sys_recvfrom-->sock_recvmsg-->sock_recvmsg_nosec-->inet_recvmsg-->tcp_recvmsg-->tcp_recvmsg_locked

static int tcp_recvmsg_locked(struct sock *sk, struct msghdr *msg, size_t len,
			      int flags, struct scm_timestamping_internal *tss,
			      int *cmsg_flags)
{
	struct tcp_sock *tp = tcp_sk(sk);
	int copied = 0;
	u32 peek_seq;
	u32 *seq;
	unsigned long used;
	int err;
	int target;		/* Read at least this many bytes */
	long timeo;
	struct sk_buff *skb, *last;
	u32 urg_hole = 0;
    //接收队列数据包
    skb_queue_walk(&sk->sk_receive_queue, skb) {
    last = skb;
    }
    ......
       //已读取足够的数据,处理 backlog
	if (copied >= target) {
            __sk_flush_backlog(sk);
        } else {
        //无数据可读
            tcp_cleanup_rbuf(sk, copied);//清理
            sk_wait_data(sk, &timeo, last);//阻塞当前进程
        }
}

内核通过skb_queue_walk遍历接收队列,如果无数据可以读取/可读取数据较少,调用sk_wait_data阻塞当前进程,如何阻塞?

int sk_wait_data(struct sock *sk, long *timeo, const struct sk_buff *skb)
{
    // 定义一个等待结构 `wait`,该结构在满足条件时被唤醒
    DEFINE_WAIT_FUNC(wait, woken_wake_function);
    int rc;
    // 将当前任务添加到套接字的等待队列中
    add_wait_queue(sk_sleep(sk), &wait);
    // 设置当前套接字的等待状态标志
    sk_set_bit(SOCKWQ_ASYNC_WAITDATA, sk);
    // 调用 `sk_wait_event` 等待事件发生,直到有数据可读取或者超时
    rc = sk_wait_event(sk, timeo, skb_peek_tail(&sk->sk_receive_queue) != skb, &wait);
    // 清除等待状态标志
    sk_clear_bit(SOCKWQ_ASYNC_WAITDATA, sk);
    // 从等待队列中移除当前任务
    remove_wait_queue(sk_sleep(sk), &wait);
    return rc;
}
#define DEFINE_WAIT_FUNC(name, function)					\
	struct wait_queue_entry name = {					\
		.private	= current,					\
		.func		= function,					\
		.entry		= LIST_HEAD_INIT((name).entry),			\
	}

#define DEFINE_WAIT(name) DEFINE_WAIT_FUNC(name, autoremove_wake_function)

调用DEFINE_WAIT_FUNC定义一个等待队列项 waitprivate 字段保存了当前进程的描述符,且唤醒时会调用 woken_wake_function

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;
}

sk_sleep返回与套接字 sk 相关联的等待队列头(wait_queue_head_t 类型),进程状态被设置为TASK_INTERRUPTIBLE,调用add_wait_queue将等待队列项 wait 插入到 sock 对象的等待队列中。最终调用sk_wait_event让出CPU,使进程进入阻塞状态,开销:进程上下文切换

void add_wait_queue(struct wait_queue_head *wq_head, struct wait_queue_entry *wq_entry)
{
	unsigned long flags;

	wq_entry->flags &= ~WQ_FLAG_EXCLUSIVE;
	spin_lock_irqsave(&wq_head->lock, flags);
	__add_wait_queue(wq_head, wq_entry);
	spin_unlock_irqrestore(&wq_head->lock, flags);
}

上面步骤是进程被阻塞的过程,那么进程是如何被唤醒呢?

数据包到达网卡时发起硬中断,硬中断处理完成后进行软中断处理,因为传输的是TCP包所有经过软中断处理后交付于协议栈调用的是tcp_v4_rcv,根据数据包的头部信息中携带的四元组信息找到对应的socket,找到后进入tcp_v4_do_rcv处理。

int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
    if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */
		struct dst_entry *dst;
        ......
		tcp_rcv_established(sk, skb);
		return 0;
	}
    //非TCP_ESTABLISHED状态
}

数据包状态TCP_ESTABLISHED,表示已经建立了连接并且可以传输数据,调用tcp_rcv_established处理。

void tcp_rcv_established(struct sock *sk, struct sk_buff *skb)
{
    ....
     eaten = tcp_queue_rcv(sk, skb, &fragstolen);
     tcp_data_ready(sk);
     .....
}

调用tcp_queue_rcv将接收到的数据包放在TCP的接收队列尾部中。

static int __must_check tcp_queue_rcv(struct sock *sk, struct sk_buff *skb,
				      bool *fragstolen)
{
	int eaten;
	struct sk_buff *tail = skb_peek_tail(&sk->sk_receive_queue);
    ......
	if (!eaten) {
		__skb_queue_tail(&sk->sk_receive_queue, skb);
		skb_set_owner_r(skb, sk);
	}
	return eaten;
}

当数据准备好后调用tcp_data_ready,指针sk_data_ready指向的回调函数sock_def_readable(sock_init_data已分析)唤醒用户进程。

void tcp_data_ready(struct sock *sk)
{
	if (tcp_epollin_ready(sk, sk->sk_rcvlowat) || sock_flag(sk, SOCK_DONE))
		sk->sk_data_ready(sk);
}
void sock_def_readable(struct sock *sk)
{
	struct socket_wq *wq;

	rcu_read_lock();
    //获取套接字的接收队列
	wq = rcu_dereference(sk->sk_wq);
    //检查是否有进程在socket的等待队列
	if (skwq_has_sleeper(wq))
        //唤醒等待队列的进程
		wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN | EPOLLPRI |
						EPOLLRDNORM | EPOLLRDBAND);
    //异步唤醒
	sk_wake_async(sk, SOCK_WAKE_WAITD, POLL_IN);
	rcu_read_unlock();
}

调用wake_up_interruptible_sync_poll唤醒此socket上由于等待数据而被阻塞的进程,函数调用逻辑wake_up_interruptible_sync_poll-->__wake_up_sync_key-->__wake_up_common_lock-->__wake_up_common

static int __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
			int nr_exclusive, int wake_flags, void *key,
			wait_queue_entry_t *bookmark)
{
    ......
    list_for_each_entry_safe_from(curr, next, &wq_head->head, entry) {
        if (flags & WQ_FLAG_BOOKMARK)
         continue;
    ret = curr->func(curr, mode, wake_flags, key);
    if (ret && (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
         break;
    ......
			
}

负责从指定的等待队列中唤醒一个或多个任务,nr_exclusive为1表示多个进程阻塞在同一个socket,也只唤醒1个进程,并非进程全部唤醒。list_for_each_entry_safe_from遍历等待队列的进程,调用curr->func唤醒函数,上文分析在DEFINE_WAIT_FUNC宏中设置了回调函数autoremove_wake_function。函数调用逻辑autoremove_wake_function-->default_wake_function

int default_wake_function(wait_queue_entry_t *curr, unsigned mode, int wake_flags,
			  void *key)
{
	WARN_ON_ONCE(IS_ENABLED(CONFIG_SCHED_DEBUG) && wake_flags & ~WF_SYNC);
	return try_to_wake_up(curr->private, mode, wake_flags);
}

调用try_to_wake_up尝试唤醒等待队列的进程,传入curr->private也就是由于等待而被阻塞的进程。开销:进程上下文切换(socket等待而被阻塞的进程进入运行队列)。

同步I/O阻塞模型比较简单socket和进程是一对一存在的,针对高并发环境单台机器上承载成千上万个socket连接,那么同步I/O模型明显存在弊端:

  • 阻塞开销
    进程从运行态切换到阻塞态需保存寄存器、更新进程控制块(PCB),耗时约 1–10μs
  • 唤醒开销
    唤醒时需将进程重新加入运行队列,触发调度器决策,耗时类似。
  • 高并发问题
    当连接数激增时,频繁的上下文切换会导致CPU资源浪费,且线程/进程数受限于内存和调度能力。

你可能感兴趣的:(深入理解Linux网络,linux,网络)