• TCP协议之《内存空间管理》


    PROC文件tcp_mem包括3个TCP协议内存空间的控制值,单位为页面数,如下,分别为最小空间值34524页面,承压值46032和最大值69048个页面,此三个值与系统的内存大小相关,示例为一个3G内存的Ubuntu系统。内核中对应的变量为sysctl_tcp_mem。

    $ cat /proc/sys/net/ipv4/tcp_mem
    34524   46032   69048

    static struct ctl_table ipv4_table[] = {
        {
            .procname   = "tcp_mem",
            .maxlen     = sizeof(sysctl_tcp_mem),
            .data       = &sysctl_tcp_mem,
        },
    }

    一、TCP内存初始化
    如下函数tcp_init_mem所示,TCP协议的三个内存空间控制值以系统中空闲页面总数的16分之一为基准(limit),并且此基准值又不低于128个页面。最小值为基准值的四分之三,承压值等于基准值,最大内存值等于最小值的两倍,即百分之9.375。

    static void __init tcp_init_mem(void)
    {
        unsigned long limit = nr_free_buffer_pages() / 16;
     
        limit = max(limit, 128UL);
        sysctl_tcp_mem[0] = limit / 4 * 3;      /* 4.68 % */
        sysctl_tcp_mem[1] = limit;          /* 6.25 % */
        sysctl_tcp_mem[2] = sysctl_tcp_mem[0] * 2;  /* 9.37 % */
    }
    另外,保存控制值的数组sysctl_tcp_mem赋值给了TCP协议结构体tcp_prot的成员sysctl_mem,以便之后需要访问控制值时,可通过sysctl_mem实现。既然需要控制TCP协议占用的内存,就需要一个变量统计当前的TCP内存占用量,见变量tcp_memory_allocated。如果其值大于承压值(sysctl_mem[1]),TCP进入内存承压状态,直到其值小于最小值(sysctl_mem[0])时,TCP退出内存承压状态。memory_pressure变量表示是否处于内存承压状态。

    atomic_long_t tcp_memory_allocated;        /* Current allocated memory. */
     
    struct proto tcp_prot = {
        .name           = "TCP",
        .enter_memory_pressure  = tcp_enter_memory_pressure,
        .leave_memory_pressure  = tcp_leave_memory_pressure,
        .memory_allocated   = &tcp_memory_allocated,
        .memory_pressure    = &tcp_memory_pressure,
        .sysctl_mem     = sysctl_tcp_mem,
        .sysctl_wmem_offset = offsetof(struct net, ipv4.sysctl_tcp_wmem),
        .sysctl_rmem_offset = offsetof(struct net, ipv4.sysctl_tcp_rmem),
    }


    二、TCP内存占用统计
    基础函数sk_memory_allocated_add,通过其调用增加TCP协议的内存占用统计值(memory_allocated成员变量同tcp_memory_allocated)。

    static inline long sk_memory_allocated_add(struct sock *sk, int amt)
    {
        return atomic_long_add_return(amt, sk->sk_prot->memory_allocated);
    }
    TCP协议最重要的内存占用统计函数为__sk_mem_raise_allocated,除了为memory_allocated变量增加amt字节的统计外,还需要进行一些列的判断,保证增加后的数值在合法范围内。sk_prot_mem_limits函数的第二个参数0、1、2分别用于获取sysctl_mem数组中的索引为0、1、2的三个TCP内存控制值。以下代码逻辑可见,增加之后的内存统计值如果小于最小控制值,退出内存承压状态并正确返回。如果大于内存承压值,则进入内存承压状态,再甚者,如果大于最大值,内核将尝试抑制本次的内存分配行为(suppress_allocation分支)。

    int __sk_mem_raise_allocated(struct sock *sk, int size, int amt, int kind)
    {
        struct proto *prot = sk->sk_prot;
        long allocated = sk_memory_allocated_add(sk, amt);
     
        /* Under limit. */
        if (allocated <= sk_prot_mem_limits(sk, 0)) {
            sk_leave_memory_pressure(sk);
            return 1;
        }
        /* Under pressure. */
        if (allocated > sk_prot_mem_limits(sk, 1))
            sk_enter_memory_pressure(sk);
        /* Over hard limit. */
        if (allocated > sk_prot_mem_limits(sk, 2))
            goto suppress_allocation;
    }

    在还没有达到TCP内存使用的最大值的情况下,如果TCP套接口当前已用的接收内存大小小于TCP接收缓存tcp_rmem的最小控制值(sk_get_rmem0),或者TCP套接口已用发送内存的大小小于TCP发送缓存tcp_wmem的最小控制值时(sk_get_wmem0),本次分配成功完成。

    $ cat /proc/sys/net/ipv4/tcp_rmem
    4096    87380   6291456
    $ cat /proc/sys/net/ipv4/tcp_wmem
    4096    16384   4194304
     
    int __sk_mem_raise_allocated(struct sock *sk, int size, int amt, int kind)
    {
        if (kind == SK_MEM_RECV) {
            if (atomic_read(&sk->sk_rmem_alloc) < sk_get_rmem0(sk, prot))
                return 1;
        } else { /* SK_MEM_SEND */
            int wmem0 = sk_get_wmem0(sk, prot);
     
            if (sk->sk_type == SOCK_STREAM) {
                if (sk->sk_wmem_queued < wmem0)
                    return 1;
            } else if (refcount_read(&sk->sk_wmem_alloc) < wmem0) {
                    return 1;
            }
        }
    }

    如果TCP内存处于承压状态(sk_under_memory_pressure),并且TCP协议当前的所有套接口数量,与发送缓存sk_wmem_queued、接收缓存sk_rmem_alloc和sk_forward_alloc三者之和的乘积,小于TCP协议的内存最大控制值,认为本次分配有效。

    int __sk_mem_raise_allocated(struct sock *sk, int size, int amt, int kind)
    {
        if (sk_has_memory_pressure(sk)) {
     
            if (!sk_under_memory_pressure(sk))
                return 1;
            alloc = sk_sockets_allocated_read_positive(sk);
            if (sk_prot_mem_limits(sk, 2) > 
                alloc * sk_mem_pages(sk->sk_wmem_queued + atomic_read(&sk->sk_rmem_alloc) + sk->sk_forward_alloc))
                return 1;
        }
    }
    如果以上条件都未成立,则尝试取消本次分配,由函数sk_memory_allocated_sub实现。但是如果是TCP(SOCK_STREAM)的发送端(SK_MEM_SEND),并且套接口的发送缓存sk_sndbuf大于sk_wmem_queued的一半,并且大于sk_wmem_queued与当前分配缓存size值的和,分配操作正常完成。

    int __sk_mem_raise_allocated(struct sock *sk, int size, int amt, int kind)
    {
    suppress_allocation:
        if (kind == SK_MEM_SEND && sk->sk_type == SOCK_STREAM) {
            sk_stream_moderate_sndbuf(sk);
            if (sk->sk_wmem_queued + size >= sk->sk_sndbuf)
                return 1;
        }
        sk_memory_allocated_sub(sk, amt);
    }
    static inline void sk_stream_moderate_sndbuf(struct sock *sk)
    {
        if (!(sk->sk_userlocks & SOCK_SNDBUF_LOCK)) {
            sk->sk_sndbuf = min(sk->sk_sndbuf, sk->sk_wmem_queued >> 1);
            sk->sk_sndbuf = max_t(u32, sk->sk_sndbuf, SOCK_MIN_SNDBUF);
        }   
    }

    内核中对TCP内存的占用一是TCP发送端,一是接收端。发送端通过sk_wmem_schedule函数增加内存分配统计和合法性判断;接收端通过函数sk_rmem_schedule实现内存分配统计与合法性判断。对于发送端内核中的判断点,包括TCP的发送相关函数do_tcp_sendpages和tcp_sendmsg_locked,以及TCP分段函数tcp_fragment,tso_fragment和tcp_mtu_probe,tcp_send_syn_data和tcp_connect等函数。

    static inline bool sk_wmem_schedule(struct sock *sk, int size)
    {
        if (!sk_has_account(sk))
            return true;
        return size <= sk->sk_forward_alloc || __sk_mem_schedule(sk, size, SK_MEM_SEND);
    }
    对于接收端,内核中的判断点包括,接收相关函数tcp_rcv_established和sock_queue_rcv_skb函数等。 

    static inline bool sk_rmem_schedule(struct sock *sk, struct sk_buff *skb, int size)
    {
        if (!sk_has_account(sk))
            return true;
        return size<= sk->sk_forward_alloc || __sk_mem_schedule(sk, size, SK_MEM_RECV) || skb_pfmemalloc(skb);
    }

    三、TCP内存释放统计

    在TCP内存释放时,减少内存的统计值。基础函数为__sk_mem_reduce_allocated。其不仅执行了统计值的递减,而且之后判断了当前是否处于内存承压状态,若干内存使用以及降低至最小控制值,退出承压状态。

    static inline void sk_memory_allocated_sub(struct sock *sk, int amt)
    {
        atomic_long_sub(amt, sk->sk_prot->memory_allocated);
    }
    void __sk_mem_reduce_allocated(struct sock *sk, int amount)
    {
        sk_memory_allocated_sub(sk, amount);
     
        if (sk_under_memory_pressure(sk) && (sk_memory_allocated(sk) < sk_prot_mem_limits(sk, 0)))
            sk_leave_memory_pressure(sk);
    }
    TCP内存统计值的递减的封装函数,有sk_mem_reclaim函数,sk_mem_reclaim_partial函数和sk_mem_uncharge函数。第一个函数sk_mem_reclaim用于TCP套接口销毁之时,回收其预分配的内存配额(sk_forward_alloc),例如套接口销毁函数inet_csk_destroy_sock以及inet_sock_destruct函数对其进行调用;或者在套接口清空队列时减低内存统计值,如函数tcp_write_queue_purge对其的调用。

    void __sk_mem_reclaim(struct sock *sk, int amount)
    {
        amount >>= SK_MEM_QUANTUM_SHIFT;
        sk->sk_forward_alloc -= amount << SK_MEM_QUANTUM_SHIFT;
        __sk_mem_reduce_allocated(sk, amount);
    }
    static inline void sk_mem_reclaim(struct sock *sk)
    {
        if (!sk_has_account(sk))
            return;
        if (sk->sk_forward_alloc >= SK_MEM_QUANTUM)
            __sk_mem_reclaim(sk, sk->sk_forward_alloc);
    }
    函数sk_mem_reclaim_partial与第一个函数基本相同,区别在于其并不回收全部的套接口预分配内存配额,仅回收了(sk->sk_forward_alloc - 1)。所有其调用之处与sk_mem_reclaim由很大的差别,例如在sk_stream_alloc_skb函数,在进行skb分配之前,如果内存处于承压状态,回收部分预分配内存,以保证接下来的内存分配能够成功完成。另外在延迟ACK定时器处理函数中(tcp_delack_timer_handler),也调用了内存部分回收函数,确保ACK的顺利发送。

    static inline void sk_mem_reclaim_partial(struct sock *sk)
    {
        if (sk->sk_forward_alloc > SK_MEM_QUANTUM)
            __sk_mem_reclaim(sk, sk->sk_forward_alloc - 1);
    }
    struct sk_buff *sk_stream_alloc_skb(struct sock *sk, int size, gfp_t gfp, bool force_schedule)
    {
        if (unlikely(tcp_under_memory_pressure(sk)))
            sk_mem_reclaim_partial(sk);
    }
    第三个函数sk_mem_uncharge,如果当前套接口的预分配内存额度大于2M字节,回收1M字节,保留太多的预分配内存额度没有必要。而且,TCP发送队列的执行流程如果没有调用sk_mem_reclaim回收内存,有可能导致超过2G的内存一次性的释放,此处减缓释放的操作。

    static inline void sk_mem_uncharge(struct sock *sk, int size)
    {
        sk->sk_forward_alloc += size;
     
        if (unlikely(sk->sk_forward_alloc >= 1 << 21))
            __sk_mem_reclaim(sk, 1 << 20);
    }
    对于接收缓存中的数据包,在kfree_skb函数销毁skb之时,通过sock_rfree函数调用sk_mem_uncharge减低内存统计值。对于发送缓存中(接收和重传队列)的数据包,使用函数sk_wmem_free_skb减低内存统计,并进行内存的释放操作。例如函数tcp_write_queue_purge和tcp_rtx_queue_purge函数。

    void sock_rfree(struct sk_buff *skb)
    {
        sk_mem_uncharge(sk, len);
    }
    static inline void sk_wmem_free_skb(struct sock *sk, struct sk_buff *skb)
    {
        sk_mem_uncharge(sk, skb->truesize);
        __kfree_skb(skb);
    }

    四、TCP内存承压状态
    是否处于内存承压状态由变量tcp_memory_pressure表示,内核使用封装函数tcp_under_memory_pressure来判断内存是否承压。另外,TCP函数tcp_enter_memory_pressure和tcp_leave_memory_pressure函数分别在进入和退出承压状态时调用,进入承压状态时,tcp_memory_pressure变量记录下当前时间,在退出承压状态时,计算TCP处于承压状态的时长,保存在当前网络命名空间的统计信息中。

    unsigned long tcp_memory_pressure __read_mostly;
    static inline bool tcp_under_memory_pressure(const struct sock *sk)
    {
        return tcp_memory_pressure;
    }
    如前对函数__sk_mem_raise_allocated的描述,在当前的TCP内存使用值超过设定的承压值时,进入承压状态,当小于设定的最小值时,退出承压状态。函数__sk_mem_raise_allocated的两个封装函数sk_wmem_schedule和sk_rmem_schedule,分别用于在TCP发送路径和接收路径上统计和控制内存使用情况。

    发送路径上的函数由诸如:tcp_sendmsg_locked,do_tcp_sendpages和通用的发送skb分配函数sk_stream_alloc_skb。如下,在skb分配成功之后,增加内存使用统计值,反之,分配失败的话进入内存承压状态。有一点需要注意,即使skb分配成功,如果在随后的sk_wmem_schedule函数中的判断中,新分配的内存空间致使内存使用量超过限制,还是会导致分配失败,释放掉分配好的skb缓存。

    struct sk_buff *sk_stream_alloc_skb(struct sock *sk, int size, gfp_t gfp, bool force_schedule)
    {
        skb = alloc_skb_fclone(size + sk->sk_prot->max_header, gfp);
        if (likely(skb)) {
            if (force_schedule) {
            } else {
                mem_scheduled = sk_wmem_schedule(sk, skb->truesize);
            }
            if (likely(mem_scheduled)) {
                return skb;
            }
            __kfree_skb(skb);
        } else {
            sk->sk_prot->enter_memory_pressure(sk);
        }
    }

    接收路径上的函数诸如:__sock_queue_rcv_skb函数,tcp_data_queue_ofo和tcp_data_queue函数。如下为tcp_data_queue函数,在接收到TCP数据包之后,检查增加此数据包长度后TCP的内存占用是否超限,超限的话丢弃数据包。

    static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
    {
        struct tcp_sock *tp = tcp_sk(sk);
        
        if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
            /* Ok. In sequence. In window. */
    queue_and_out:
            if (skb_queue_len(&sk->sk_receive_queue) == 0)
                sk_forced_mem_schedule(sk, skb->truesize);
            else if (tcp_try_rmem_schedule(sk, skb, skb->truesize))
                goto drop;
        }
    }
    最后,在发送路径上,当套接口中的页面不足以存放数据包分片时,进入内存承压状态。如下函数__ip_append_data所示,只有当数据包的出口设备支持分散/聚集(NETIF_F_SG)特性时,才会执行此流程。

    bool sk_page_frag_refill(struct sock *sk, struct page_frag *pfrag)
    {               
        if (likely(skb_page_frag_refill(32U, pfrag, sk->sk_allocation)))
            return true;
     
        sk_enter_memory_pressure(sk);
    }
    static int __ip_append_data(struct sock *sk, struct flowi4 *fl4, struct sk_buff_head *queue, ...)
    {
        while (length > 0) {
            if (!(rt->dst.dev->features&NETIF_F_SG)) {
            } else {
                int i = skb_shinfo(skb)->nr_frags;
                if (!sk_page_frag_refill(sk, pfrag))
                    goto error;
            }
        }
    }

    五、TCP内存超限
    除了以上介绍的__sk_mem_raise_allocated函数之外,tcp_out_of_memory函数也用于TCP内存超过设定的最大值的判断。sk_prot_mem_limits函数第二个参数为2表示获取出设定的TCP内存最大限值。其被函数tcp_check_oom所封装,如果TCP当前内存超限,内核会有打印信息输出到终端上。

    static inline bool tcp_out_of_memory(struct sock *sk)
    {
        if (sk->sk_wmem_queued > SOCK_MIN_SNDBUF && sk_memory_allocated(sk) > sk_prot_mem_limits(sk, 2))
            return true;
        return false;
    }
    bool tcp_check_oom(struct sock *sk, int shift)
    {
        out_of_socket_memory = tcp_out_of_memory(sk);
        if (out_of_socket_memory)
            net_info_ratelimited("out of memory -- consider tuning tcp_mem\n");
        return  out_of_socket_memory;
    }
    在函数tcp_close函数和一系列的TCP定时器函数如tcp_write_timeout和tcp_probe_timer中检测TCP的OOM情况。以上定时器函数通过调用tcp_out_of_resources,间接调用tcp_check_oom函数。


    六、TCP内存分配的特例

    在特定的情况下,内核允许TCP内存的分配不受内存限额的约束。参见以下函数sk_forced_mem_schedule,与之上函数__sk_mem_raise_allocated不同,其直接增加内存的分配统计,不考虑任何的超限情况。

    void sk_forced_mem_schedule(struct sock *sk, int size)
    {
        if (size <= sk->sk_forward_alloc)
            return;
        amt = sk_mem_pages(size);
        sk->sk_forward_alloc += amt * SK_MEM_QUANTUM;
        sk_memory_allocated_add(sk, amt);
    }
    特定的情况包括,TCP的FIN报文发送等,内核尽可能的保证FIN报文的成功发送,以便尽快的结束一个TCP连接。

    void tcp_send_fin(struct sock *sk)
    {
        struct sk_buff *skb, *tskb = tcp_write_queue_tail(sk);
     
        if (tskb) {
        } else {
            skb = alloc_skb_fclone(MAX_TCP_HEADER, sk->sk_allocation);
            sk_forced_mem_schedule(sk, skb->truesize);
        }
    }
    另外,在TCP套接口接收到一个正常序号的数据包,并且其位于接收窗口之内,而此时的接收队列又是为空,内核强制增加内存的统计值,以保证其正确接收。这样可避免对端无谓的重传,但是又能保护TCP的内存不至于超限被过度使用(接收队列为空判断时执行)。在函数tcp_data_queue中,与以上tcp_send_fin不同,内核仅增加内存统计值,并未实际分配内存,数据包skb内存由网卡驱动程序分配而来。

    static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
    {
        struct tcp_sock *tp = tcp_sk(sk);
     
        if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
            /* Ok. In sequence. In window. */
    queue_and_out:
            if (skb_queue_len(&sk->sk_receive_queue) == 0)
                sk_forced_mem_schedule(sk, skb->truesize);
        }
    }
    再者,在TCP分配内存时,可通过参数force_schedule指定强制增加内存统计,不受限定值约束。目前在内核中TCP分片相关函数tcp_fragment和tso_fragment,TCP连接发起函数tcp_connect在使用此强制选项,这些函数的alloc_skb_fcone操作不受限定值约束,只要系统内存做够,即可分配成功。

    struct sk_buff *sk_stream_alloc_skb(struct sock *sk, int size, gfp_t gfp, bool force_schedule)
    {
        skb = alloc_skb_fclone(size + sk->sk_prot->max_header, gfp);
        if (likely(skb)) {
            if (force_schedule) {
                mem_scheduled = true;
                sk_forced_mem_schedule(sk, skb->truesize);
            }
        }
    }
    最后,在TCP发送函数,无论是do_tcp_sendpages还是tcp_sendmsg_locked函数中,在TCP的重传队列和发送队列都为空的情况下,即tcp_rtx_and_write_queues_empty为真,说明发送缓冲区都已清空,可强制进行内存分配,并增加内存统计。

    ssize_t do_tcp_sendpages(struct sock *sk, struct page *page, int offset, size_t size, int flags)
    {
        while (size > 0) {
            struct sk_buff *skb = tcp_write_queue_tail(sk);
     
            if (!skb || (copy = size_goal - skb->len) <= 0 || !tcp_skb_can_collapse_to(skb))
                skb = sk_stream_alloc_skb(sk, 0, sk->sk_allocation, tcp_rtx_and_write_queues_empty(sk));

    int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
    {
        while (msg_data_left(msg)) {
            if (copy <= 0 || !tcp_skb_can_collapse_to(skb)) {
                first_skb = tcp_rtx_and_write_queues_empty(sk);
                skb = sk_stream_alloc_skb(sk, select_size(sk, sg, first_skb), sk->sk_allocation, first_skb);
            }
        }
    }

    七、TCP内存承压与超限的影响
    如函数sk_stream_alloc_skb所示,在内存承压之后,内核开始回收已分配的内存。并且停止TCP内存分配相关的操作。

    struct sk_buff *sk_stream_alloc_skb(struct sock *sk, int size, gfp_t gfp, bool force_schedule)
    {
        if (unlikely(tcp_under_memory_pressure(sk)))
            sk_mem_reclaim_partial(sk);
    }
    如FIN报文发送函数tcp_send_fin,在进入内存承压状态后,试图避免单独的FIN报文分配,将FIN标志设置到重传队列的最后一个数据包上。

    void tcp_send_fin(struct sock *sk)
    {
        struct sk_buff *skb, *tskb = tcp_write_queue_tail(sk);
     
        if (!tskb && tcp_under_memory_pressure(sk))
            tskb = skb_rb_last(&sk->tcp_rtx_queue);
    }
    另外,停止TCP接收窗口的增长,以及停止发送缓存的增长。

    static void tcp_grow_window(struct sock *sk, const struct sk_buff *skb)
    {
        struct tcp_sock *tp = tcp_sk(sk);
        if (tp->rcv_ssthresh < tp->window_clamp && (int)tp->rcv_ssthresh < tcp_space(sk) &&
            !tcp_under_memory_pressure(sk)) {
        } 
    }
    static bool tcp_should_expand_sndbuf(const struct sock *sk)
    {
        if (tcp_under_memory_pressure(sk))
            return false;
        if (sk_memory_allocated(sk) >= sk_prot_mem_limits(sk, 0))
            return false;
    }
    再者,对于TCP的接收路径,如果内存处于承压状态,丢掉进入的数据包不在接收。对于发送端函数,诸如tcp_sendmsg_locked或者do_tcp_sendpages函数,在内存承压时,可进入等待队列,即函数sk_stream_wait_memory等待内存可用。内存可用时内核使用函数sk_stream_write_space唤醒等待的队列。

    static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)
    {
        if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {
    queue_and_out:
            if (skb_queue_len(&sk->sk_receive_queue) == 0)
                sk_forced_mem_schedule(sk, skb->truesize);
            else if (tcp_try_rmem_schedule(sk, skb, skb->truesize))
                goto drop;
    }
    int tcp_sendmsg_locked(struct sock *sk, struct msghdr *msg, size_t size)
    {
        while (msg_data_left(msg)) {
            if (skb_availroom(skb) > 0) {
            } else if (!uarg || !uarg->zerocopy) {
                if (!sk_page_frag_refill(sk, pfrag))
                    goto wait_for_memory;
                if (!sk_wmem_schedule(sk, copy))
                    goto wait_for_memory;
            }
    wait_for_memory:
            err = sk_stream_wait_memory(sk, &timeo);
        }
    }

    八、PROC文件protocols
    PROC文件/proc/net/protocols可查看当前各个协议包括TCP的内存使用情况,已经承压状态,如下所示:

     

  • 相关阅读:
    黑马Java第六讲—基本练习
    互联网快讯:天猫好房正式入驻六安;搜狗又一业务关停
    「Java分享客栈」Nacos配置中心称王称霸,我Apollo一生也不弱于人!
    GIS前端-地图事件编程
    shell生成1到100个不同的随机数
    2024级199管理类联考之英语二2200核心词汇(第三天)
    论文解读(GIN)《How Powerful are Graph Neural Networks》
    设计模式(3)-结构型模式
    我在 NPM 发布了新包: con-colors
    这个 堆排序详解过程 我能吹一辈子!!!
  • 原文地址:https://blog.csdn.net/wuyongmao/article/details/126266302