高性能网络编程总结及《TCP/IP Sockets编程(C语言实现) (第2版)》 代码下载(链接以及文件打包)

http://blog.csdn.net/column/details/high-perf-network.html

http://blog.csdn.net/russell_tao/article/details/9111769

高性能网络编程(一)----accept建立连接

最近在部门内做了个高性能网络编程的培训,近日整理了下PPT,欲写成一系列文章从应用角度谈谈它。
编写服务器时,许多程序员习惯于使用高层次的组件、中间件(例如OO(面向对象)层层封装过的开源组件),相比于服务器的运行效率而言,他们更关注程序开发的效率,追求更快的完成项目功能点、希望应用代码完全不关心通讯细节。他们更喜欢在OO世界里,去实现某个接口、实现这个组件预定义的各种模式、设置组件参数来达到目的。学习复杂的通讯框架、底层细节,在习惯于使用OO语言的程序员眼里是绝对事倍功半的。以上做法无可厚非,但有一定的局限性,本文讲述的网络编程头前冠以“高性能”,它是指程序员设计编写的服务器需要处理很大的吞吐量,这与简单网络应用就有了质的不同。因为:1、高吞吐量下,容易触发到一些设计上的边界条件;2、偶然性的小概率事件,会在高吞吐量下变成必然性事件。3、IO是慢速的,高吞吐量通常意味着高并发,如同一时刻存在数以万计、十万计、百万计的TCP活动连接。所以,做高性能网络编程不能仅仅满足于学会开源组件、中间件是如何帮我实现期望功能的,对于企业级产品来说,需要了解更多的知识。

掌握高性能网络编程,涉及到对网络、操作系统协议栈、进程与线程、常见的网络组件等知识点,需要有丰富的项目开发经验,能够权衡服务器运行效率与项目开发效率。以下图来谈谈我个人对高性能网络编程的理解。

上面这张图中,由上至下有以下特点:
•关注点,逐渐由特定业务向通用技术转移
•使用场景上,由专业领域向通用领域转移
•灵活性上要求越来越高
•性能要求越来越高
•对细节、原理的掌握,要求越来越高
•对各种异常情况的处理,要求越来越高
•稳定性越来越高,bug率越来越少
在做应用层的网络编程时,若服务器吞吐量大,则应该适度了解以上各层的关注点。

如上图红色文字所示,我认为编写高性能服务器的关注点有3个:
1、如果基于通用组件编程,关注点多是在组件如何封装套接字编程细节。为了使应用程序不感知套接字层,这些组件往往是通过各种回调机制来向应用层代码提供网络服务,通常,出于为应用层提供更高的开发效率,组件都大量使用了线程(Nginx等是个例外),当然,使用了线程后往往可以降低代码复杂度。但多线程引入的并发解决机制还是需要重点关注的,特别是锁的使用。另外,使用多线程意味着把应用层的代码复杂度扔给了操作系统,大吞吐量时,需要关注多线程给操作系统内核带来的性能损耗。
基于通用组件编程,为了程序的高性能运行,需要清楚的了解组件的以下特性:怎么使用IO多路复用或者异步IO的?怎么实现并发性的? 怎么组织线程模型的? 怎么处理高吞吐量引发的异常情况的?

2、通用组件只是在封装套接字,操作系统是通过提供套接字来为进程提供网络通讯能力的。所以,不了解套接字编程,往往对组件的性能就没有原理上的认识。学习套接字层的编程是有必要的,或许很少会自己从头去写,但操作系统的API提供方式经久不变,一经学会,受用终身,同时在项目的架构设计时,选用何种网络组件就非常准确了。
学习套接字编程,关注点主要在:套接字的编程方法有哪些?阻塞套接字的各方法是如何阻塞住当前代码段的?非阻塞套接字上的方法如何不阻塞当前代码段的?IO多路复用机制是怎样与套接字结合的?异步IO是如何实现的?网络协议的各种异常情况、操作系统的各种异常情况是怎么通过套接字传递给应用性程序的?

3、网络的复杂性会影响到服务器的吞吐量,而且,高吞吐量场景下,多种临界条件会导致应用程序的不正常,特别是组件中有bug或考虑不周或没有配置正确时。了解网络分组可以定位出这些问题,可以正确的配置系统、组件,可以正确的理解系统的瓶颈。
这里的关注点主要在:TCP、UDP、IP协议的特点?linux等操作系统如何处理这些协议的?使用tcpdump等抓包工具分析各网络分组。

一般掌握以上3点,就可以挥洒自如的实现高性能网络服务器了。

下面具体谈谈如何做到高性能网络编程。
众所周知,IO是计算机上最慢的部分,先不看磁盘IO,针对网络编程,自然是针对网络IO。网络协议对网络IO影响很大,当下, TCP/IP协议是毫无疑问的主流协议,本文就主要以TCP协议为例来说明网络IO。
网络IO中应用服务器往往聚焦于以下几个由网络IO组成的功能中:A)与客户端建立起TCP连接。B)读取客户端的请求流。C)向客户端发送响应流。D)关闭TCP连接。E)向其他服务器发起TCP连接。
要掌握住这5个功能,不仅仅需要熟悉一些API的使用,更要理解底层网络如何与上层API之间互相发生影响。同时,还需要对不同的场景下,如何权衡开发效率、进程、线程与这些API的组合使用。下面依次来说说这些网络IO。


1、 与客户端建立起TCP连接
谈这个功能前,先来看看网络、协议、应用服务器间的关系

上图中可知:
为简化不同场景下的编程,TCP/IP协议族划分了应用层、TCP传输层、IP网络层、链路层等,每一层只专注于少量功能。
例如,IP层只专注于每一个网络分组如何到达目的主机,而不管目的主机如何处理。
传输层最基本的功能是专注于端到端,也就是一台主机上的进程发出的包,如何到达目的主机上的某个进程。当然,TCP层为了可靠性,还额外需要解决3个大问题:丢包(网络分组在传输中存在的丢失)、重复(协议层异常引发的多个相同网络分组)、延迟(很久后网络分组才到达目的地)。
链路层则只关心以太网或其他二层网络内网络包的传输。

回到应用层,往往只需要调用类似于accept的API就可以建立TCP连接。建立连接的流程大家都了解--三次握手,它如何与accept交互呢?下面以一个不太精确却通俗易懂的图来说明之:

研究过backlog含义的朋友都很容易理解上图。这两个队列是内核实现的,当服务器绑定、监听了某个端口后,这个端口的SYN队列和ACCEPT队列就建立好了。客户端使用connect向服务器发起TCP连接,当图中1.1步骤客户端的SYN包到达了服务器后,内核会把这一信息放到SYN队列(即未完成握手队列)中,同时回一个SYN+ACK包给客户端。一段时间后,在较中2.1步骤中客户端再次发来了针对服务器SYN包的ACK网络分组时,内核会把连接从SYN队列中取出,再把这个连接放到ACCEPT队列(即已完成握手队列)中。而服务器在第3步调用accept时,其实就是直接从ACCEPT队列中取出已经建立成功的连接套接字而已。

现有我们可以来讨论应用层组件:为何有的应用服务器进程中,会单独使用1个线程,只调用accept方法来建立连接,例如tomcat;有的应用服务器进程中,却用1个线程做所有的事,包括accept获取新连接。

原因在于:首先,SYN队列和ACCEPT队列都不是无限长度的,它们的长度限制与调用listen监听某个地址端口时传递的backlog参数有关。既然队列长度是一个值,那么,队列会满吗?当然会,如果上图中第1步执行的速度大于第2步执行的速度,SYN队列就会不断增大直到队列满;如果第2步执行的速度远大于第3步执行的速度,ACCEPT队列同样会达到上限。第1、2步不是应用程序可控的,但第3步却是应用程序的行为,假设进程中调用accept获取新连接的代码段长期得不到执行,例如获取不到锁、IO阻塞等。

那么,这两个队列满了后,新的请求到达了又将发生什么?
若SYN队列满,则会直接丢弃请求,即新的SYN网络分组会被丢弃;如果ACCEPT队列满,则不会导致放弃连接,也不会把连接从SYN列队中移出,这会加剧SYN队列的增长。所以,对应用服务器来说,如果ACCEPT队列中有已经建立好的TCP连接,却没有及时的把它取出来,这样,一旦导致两个队列满了后,就会使客户端不能再建立新连接,引发严重问题。
所以,如TOMCAT等服务器会使用独立的线程,只做accept获取连接这一件事,以防止不能及时的去accept获取连接。

那么,为什么如Nginx等一些服务器,在一个线程内做accept的同时,还会做其他IO等操作呢?
这里就带出阻塞和非阻塞的概念。应用程序可以把listen时设置的套接字设为非阻塞模式(默认为阻塞模式),这两种模式会导致accept方法有不同的行为。对阻塞套接字,accept行为如下图:

这幅图中可以看到,阻塞套接字上使用accept,第一个阶段是等待ACCEPT队列不为空的阶段,它耗时不定,由客户端是否向自己发起了TCP请求而定,可能会耗时很长。
对非阻塞套接字,accept会有两种返回,如下图:

非阻塞套接字上的accept,不存在等待ACCEPT队列不为空的阶段,它要么返回成功并拿到建立好的连接,要么返回失败。

所以,企业级的服务器进程中,若某一线程既使用accept获取新连接,又继续在这个连接上读、写字符流,那么,这个连接对应的套接字通常要设为非阻塞。原因如上图,调用accept时不会长期占用所属线程的CPU时间片,使得线程能够及时的做其他工作。


 

高性能网络编程2----TCP消息的发送

标签: tcpkernelnaglesocketlinux
  17001人阅读  评论(33)  收藏  举报
  分类:
 
   
上一篇中,我们已经建立好的TCP连接,对应着操作系统分配的1个套接字。操作TCP协议发送数据时,面对的是数据流。通常调用诸如send或者write方法来发送数据到另一台主机,那么,调用这样的方法时,在操作系统内核中发生了什么事情呢?我们带着以下3个问题来细细分析:发送方法成功返回时,能保证TCP另一端的主机接收到吗?能保证数据已经发送到网络上了吗?套接字为阻塞或者非阻塞时,发送方法做的事情有何不同?

要回答上面3个问题涉及了不少知识点,我们先在TCP层面上看看,发送方法调用时内核做了哪些事。我不想去罗列内核中的数据结构、方法等,毕竟大部分应用程序开发者不需要了解这些,仅以一幅示意图粗略表示,如下:

图1 一种典型场景下发送TCP消息的流程
再详述上图10个步骤前,先要澄清几个概念:MTU、MSS、 tcp_write_queue发送队列、阻塞与非阻塞套接字、拥塞窗口、滑动窗口、 Nagle算法
当我们调用发送方法时,会把我们代码中构造好的消息流作为参数传递。这个消息流可大可小,例如几个字节,或者几兆字节。当消息流较大时,将有可能出现分片。我们先来讨论分片问题。

1、MSS与TCP的分片
由上一篇文中可知,TCP层是第4层传输层,第3层IP网络层、第2层数据链路层具备的约束条件同样对TCP层生效。下面来看看数据链路层中的一个概念:最大传输单元MTU。
无论何种类型的数据链路层,都会对网络分组的长度有一个限制。例如以太网限制为1500字节,802.3限制为1492字节。当内核的IP网络层试图发送报文时,若一个报文的长度大于MTU限制,就会被分成若干个小于MTU的报文,每个报文都会有独立的IP头部。

看看IP头部的格式:

图2 IP头部格式
可以看到,其指定IP包总长度的是一个16位(2字节)的字段,这意味一个IP包最大可以是65535字节。
若TCP层在以太网中试图发送一个大于1500字节的消息,调用IP网络层方法发送消息时,IP层会自动的获取所在局域网的MTU值,并按照所在网络的MTU大小来分片。IP层同时希望这个分片对于传输层来说是透明的,接收方的IP层会根据收到的多个IP包头部,将发送方IP层分片出的IP包重组为一个消息。
这种IP层的分片效率是很差的,因为必须所有分片都到达才能重组成一个包,其中任何一个分片丢失了,都必须重发所有分片。所以,TCP层会试图避免IP层执行数据报分片。

为了避免IP层的分片,TCP协议定义了一个新的概念:最大报文段长度MSS。它定义了一个TCP连接上,一个主机期望对端主机发送单个报文的最大长度。TCP3次握手建立连接时,连接双方都要互相告知自己期望接收到的MSS大小。例如(使用tcpdump抓包):
15:05:08.230782 IP 10.7.80.57.64569 > houyi-vm02.dev.sd.aliyun.com.tproxy: S 3027092051:3027092051(0) win 8192 <mss 1460,nop,wscale 8,nop,nop,sackOK>
15:05:08.234267 IP houyi-vm02.dev.sd.aliyun.com.tproxy > 10.7.80.57.64569: S 26006838:26006838(0) ack 3027092052 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 9>
15:05:08.233320 IP 10.7.80.57.64543 > houyi-vm02.dev.sd.aliyun.com.tproxy: P 78972532:78972923(391) ack 12915963 win 255
由于例子中两台主机都在以太网内,以太网的MTU为1500,减去IP和TCP头部的长度,MSS就是1460,三次握手中,SYN包都会携带期望的MSS大小。

当应用层调用TCP层提供的发送方法时,内核的TCP模块在tcp_sendmsg方法里,会按照对方告知的MSS来分片,把消息流分为多个网络分组(如图1中的3个网络分组),再调用IP层的方法发送数据。

这个MSS就不会改变了吗?
会的。上文说过,MSS就是为了避免IP层分片,在建立握手时告知对方期望接收的MSS值并不一定靠得住。因为这个值是预估的,TCP连接上的两台主机若处于不同的网络中,那么,连接上可能有许多中间网络,这些网络分别具有不同的数据链路层,这样,TCP连接上有许多个MTU。特别是,若中间途径的MTU小于两台主机所在的网络MTU时,选定的MSS仍然太大了,会导致中间路由器出现IP层的分片。
怎样避免中间网络可能出现的分片呢?
通过IP头部的DF标志位,这个标志位是告诉IP报文所途经的所有IP层代码:不要对这个报文分片。如果一个IP报文太大必须要分片,则直接返回一个ICMP错误,说明必须要分片了,且待分片路由器网络接受的MTU值。这样,连接上的发送方主机就可以重新确定MSS。


2、发送方法返回成功后,数据一定发送到了TCP的另一端吗?
答案当然是否定的。解释这个问题前,先来看看TCP是如何保证可靠传输的。
TCP把自己要发送的数据流里的每一个字节都看成一个序号,可靠性是要求连接对端在接收到数据后,要发送ACK确认,告诉它已经接收到了多少字节的数据。也就是说,怎样确保数据一定发送成功了呢?必须等待发送数据对应序号的ACK到达,才能确保数据一定发送成功。 TCP层提供的send或者write这样的方法是不会做这件事的,看看图1,它究竟做了哪些事。

图1中分为10步。
(1)应用程序试图调用send方法来发送一段较长的数据。
(2)内核主要通过tcp_sendmsg方法来完成。
(3)(4)内核真正执行报文的发送,与send方法的调用并不是同步的。即,send方法返回成功了,也不一定把IP报文都发送到网络中了。因此,需要把用户需要发送的用户态内存中的数据,拷贝到内核态内存中,不依赖于用户态内存,也使得进程可以快速释放发送数据占用的用户态内存。但这个拷贝操作并不是简单的复制,而是把待发送数据,按照MSS来划分成多个尽量达到MSS大小的分片报文段,复制到内核中的sk_buff结构来存放,同时把这些分片组成队列,放到这个TCP连接对应的 tcp_write_queue发送队列中
(5)内核中为这个TCP连接分配的内核缓存是有限的( /proc/sys/net/core/wmem_default )。当没有多余的内核态缓存来复制用户态的待发送数据时,就需要调用一个方法 sk_stream_wait_memory来等待滑动窗口移动,释放出一些缓存出来(收到ACK后,不需要再缓存原来已经发送出的报文,因为既然已经确认对方收到,就不需要定时重发,自然就释放缓存了)。例如:
[cpp]  view plain  copy
  1. wait_for_memory:  
  2.             if (copied)  
  3.                 tcp_push(sk, tp, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);  
  4.   
  5.             if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)  
  6.                 goto do_error;  

这里的 sk_stream_wait_memory方法接受一个参数timeo,就是等待超时的时间。这个时间是tcp_sendmsg方法刚开始就拿到的,如下:
[cpp]  view plain  copy
  1. timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);  

看看其实现:
[cpp]  view plain  copy
  1. static inline long sock_sndtimeo(const struct sock *sk, int noblock)  
  2. {  
  3.     return noblock ? 0 : sk->sk_sndtimeo;  
  4. }  

也就是说,当这个套接字是阻塞套接字时,timeo就是SO_SNDTIMEO选项指定的发送超时时间。如果这个套接字是非阻塞套接字, timeo变量就会是0。
实际上,sk_stream_wait_memory对于非阻塞套接字会直接返回,并将 errno错误码置为EAGAIN。
(6)在图1的例子中,我们假定使用了阻塞套接字,且等待了足够久的时间,收到了对方的ACK,滑动窗口释放出了缓存。
(7)将剩下的用户态数据都组成MSS报文拷贝到内核态的sk_buff中。
(8)最后,调用tcp_push等方法,它最终会调用IP层的方法来发送tcp_write_queue队列中的报文。
注意,IP层返回时,并不一定是把报文发送了出去。
(9)(10)发送方法返回。

从图1的10个步骤中可知,无论是使用阻塞还是非阻塞套接字,发送方法成功返回时(无论全部成功或者部分成功),既不代表TCP连接的另一端主机接收到了消息,也不代表本机把消息发送到了网络上,只是说明,内核将会试图保证把消息送达对方。


3、 Nagle算法、 滑动窗口、拥塞窗口对发送方法的影响
图1第8步tcp_push方法做了些什么呢?先来看看主要的流程:

图3 发送TCP消息的简易流程

下面简单看看这几个概念:
(1)滑动窗口
滑动窗口大家都比较熟悉,就不详细介绍了。TCP连接上的双方都会通知对方自己的接收窗口大小。而对方的接收窗口大小就是自己的发送窗口大小。tcp_push在发送数据时当然需要与发送窗口打交道。发送窗口是一个时刻变化的值,随着ACK的到达会变大,随着发出新的数据包会变小。当然,最大也只能到三次握手时对方通告的窗口大小。tcp_push在发送数据时,最终会使用tcp_snd_wnd_test方法来判断当前待发送的数据,其序号是否超出了发送滑动窗口的大小,例如:
[cpp]  view plain  copy
  1. //检查这一次要发送的报文最大序号是否超出了发送滑动窗口大小  
  2. static inline int tcp_snd_wnd_test(struct tcp_sock *tp, struct sk_buff *skb, unsigned int cur_mss)  
  3. {  
  4.         //end_seq待发送的最大序号  
  5.     u32 end_seq = TCP_SKB_CB(skb)->end_seq;  
  6.   
  7.     if (skb->len > cur_mss)  
  8.         end_seq = TCP_SKB_CB(skb)->seq + cur_mss;  
  9.   
  10.         //snd_una是已经发送过的数据中,最小的没被确认的序号;而snd_wnd就是发送窗口的大小  
  11.     return !after(end_seq, tp->snd_una + tp->snd_wnd);  
  12. }  


(2)慢启动和拥塞窗口
由于两台主机间的网络可能很复杂,通过广域网时,中间的路由器转发能力可能是瓶颈。也就是说,如果一方简单的按照另一方主机三次握手时通告的滑动窗口大小来发送数据的话,可能会使得网络上的转发路由器性能雪上加霜,最终丢失更多的分组。这时,各个操作系统内核都会对TCP的发送阶段加入慢启动和拥塞避免算法。慢启动算法说白了,就是对方通告的窗口大小只表示对方接收TCP分组的能力,不表示中间网络能够处理分组的能力。所以,发送方请悠着点发,确保网络非常通畅了后,再按照对方通告窗口来敞开了发。
拥塞窗口就是下面的cwnd,它用来帮助慢启动的实现。连接刚建立时,拥塞窗口的大小远小于发送窗口,它实际上是一个MSS。每收到一个ACK,拥塞窗口扩大一个MSS大小,当然,拥塞窗口最大只能到对方通告的接收窗口大小。当然,为了避免指数式增长,拥塞窗口大小的增长会更慢一些,是线性的平滑的增长过程。
所以,在tcp_push发送消息时,还会检查拥塞窗口,飞行中的报文数要小于拥塞窗口个数,而发送数据的长度也要小于拥塞窗口的长度。
如下所示,首先用 unsigned int tcp_cwnd_test方法检查飞行的报文数是否小于拥塞窗口个数(多少个MSS的个数)
[cpp]  view plain  copy
  1. static inline unsigned int tcp_cwnd_test(struct tcp_sock *tp, struct sk_buff *skb)  
  2. {  
  3.     u32 in_flight, cwnd;  
  4.   
  5.     /* Don't be strict about the congestion window for the final FIN.  */  
  6.     if (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN)  
  7.         return 1;  
  8.   
  9.         //飞行中的数据,也就是没有ACK的字节总数  
  10.     in_flight = tcp_packets_in_flight(tp);  
  11.     cwnd = tp->snd_cwnd;  
  12.         //如果拥塞窗口允许,需要返回依据拥塞窗口的大小,还能发送多少字节的数据  
  13.     if (in_flight < cwnd)  
  14.         return (cwnd - in_flight);  
  15.   
  16.     return 0;  
  17. }  

再通过tcp_window_allows方法获取拥塞窗口与滑动窗口的最小长度,检查待发送的数据是否超出:
[cpp]  view plain  copy
  1. static unsigned int tcp_window_allows(struct tcp_sock *tp, struct sk_buff *skb, unsigned int mss_now, unsigned int cwnd)  
  2. {  
  3.     u32 window, cwnd_len;  
  4.   
  5.     window = (tp->snd_una + tp->snd_wnd - TCP_SKB_CB(skb)->seq);  
  6.     cwnd_len = mss_now * cwnd;  
  7.     return min(window, cwnd_len);  
  8. }  


(3)是否符合NAGLE算法?
Nagle算法的初衷是这样的:应用进程调用发送方法时,可能每次只发送小块数据,造成这台机器发送了许多小的TCP报文。对于整个网络的执行效率来说,小的TCP报文会增加网络拥塞的可能,因此,如果有可能,应该将相临的TCP报文合并成一个较大的TCP报文(当然还是小于MSS的)发送。
Nagle算法要求一个TCP连接上最多只能有一个发送出去还没被确认的小分组,在该分组的确认到达之前不能发送其他的小分组。
内核中是通过  tcp_nagle_test 方法实现该算法的。我们简单的看下:
[cpp]  view plain  copy
  1.   
[cpp]  view plain  copy
  1. static inline int tcp_nagle_test(struct tcp_sock *tp, struct sk_buff *skb,  
  2.                  unsigned int cur_mss, int nonagle)  
  3. {  
  4.     //nonagle标志位设置了,返回1表示允许这个分组发送出去  
  5.     if (nonagle & TCP_NAGLE_PUSH)  
  6.         return 1;  
  7.   
  8.     //如果这个分组包含了四次握手关闭连接的FIN包,也可以发送出去  
  9.     if (tp->urg_mode ||  
  10.         (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN))  
  11.         return 1;  
  12.   
  13.         //检查Nagle算法  
  14.     if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))  
  15.         return 1;  
  16.   
  17.     return 0;  
  18. }  

再来看看 tcp_nagle_check方法,它与上一个方法不同,返回0表示可以发送,返回非0则不可以,正好相反。
[cpp]  view plain  copy
  1. static inline int tcp_nagle_check(const struct tcp_sock *tp,  
  2.                   const struct sk_buff *skb,   
  3.                   unsigned mss_now, int nonagle)  
  4. {  
  5.         //先检查是否为小分组,即报文长度是否小于MSS  
  6.     return (skb->len < mss_now &&  
  7.         ((nonagle&TCP_NAGLE_CORK) ||  
  8.         //如果开启了Nagle算法  
  9.          (!nonagle &&  
  10.         //若已经有小分组发出(packets_out表示“飞行”中的分组)还没有确认  
  11.           tp->packets_out &&  
  12.           tcp_minshall_check(tp))));  
  13. }  

最后看看tcp_minshall_check做了些什么:
[cpp]  view plain  
  • 8
    点赞
  • 38
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值