Linux·Illustration du processus d'envoi de paquets réseau

Merci de réfléchir à quelques petites questions.

  • Question 1 : Quand on regarde le CPU consommé par le noyau qui envoie des données, faut-il regarder sy ou si ?
  • Question 2 : Pourquoi NET_RX est-il beaucoup plus grand que NET_TX dans /proc/softirqs sur votre serveur ?
  • Q3 : Quelles opérations de copie de mémoire sont impliquées dans l'envoi de données réseau ?

Bien que ces problèmes soient souvent rencontrés en ligne, il semble que nous les approfondissions rarement. Si nous pouvons vraiment bien comprendre ces problèmes, notre capacité à contrôler les performances deviendra plus forte.

Avec ces trois questions, nous commençons aujourd'hui l'analyse approfondie du processus d'envoi réseau du noyau Linux. Toujours dans le respect de notre ancienne tradition, commencez par un simple bout de code. Le code suivant est un microcode typique d'un programme serveur typique :

int main(){
 fd = socket(AF_INET, SOCK_STREAM, 0);
 bind(fd, ...);
 listen(fd, ...);

 cfd = accept(fd, ...);

 // 接收用户请求
 read(cfd, ...);

 // 用户请求处理
 dosometing(); 

 // 给用户返回结果
 send(cfd, buf, sizeof(buf), 0);
}

Aujourd'hui, nous allons discuter de la façon dont le noyau envoie le paquet de données après avoir appelé send dans le code ci-dessus. Cet article est basé sur Linux 3.10 et le pilote de la carte réseau utilise la carte réseau igb d'Intel comme exemple.

Attention : cet article contient plus de 10 000 mots et 25 images, attention aux articles longs !

1. Présentation du processus d'envoi du réseau Linux

Je pense que la chose la plus importante à regarder le code source de Linux est d'avoir une compréhension globale, plutôt que de s'enliser dans divers détails dès le début.

Je vous ai préparé ici un organigramme général et expliqué brièvement comment les données envoyées par envoi sont envoyées à la carte réseau étape par étape.

Dans cette image, nous voyons que les données utilisateur sont copiées dans l'état du noyau, puis entrées dans le RingBuffer après avoir été traitées par la pile de protocoles. Ensuite, le pilote de la carte réseau envoie réellement les données. Lorsque la transmission est terminée, le CPU est averti par une interruption matérielle, puis le RingBuffer est nettoyé.

Étant donné que le code source sera entré plus tard dans l'article, nous donnerons un organigramme du point de vue du code source.

Bien que les données aient été envoyées à ce moment, il y a en fait une chose importante qui n'a pas été faite, qui est de libérer la mémoire telle que la file d'attente du cache.

Comment le noyau sait-il quand libérer la mémoire, bien sûr après l'envoi du réseau. Lorsque la carte réseau termine l'envoi, elle enverra une interruption matérielle au CPU pour notifier le CPU. Voir le diagramme pour un processus plus complet :

Notez que bien que notre sujet d'aujourd'hui porte sur l'envoi de données, l'interruption logicielle déclenchée par l'interruption matérielle est NET_RX_SOFTIRQ au lieu de NET_TX_SOFTIRQ ! ! ! (T est l'abréviation de transmettre, R signifie recevoir)

Est-ce une surprise, est-ce une surprise ? ? ?

C'est donc en partie la raison de l'ouverture de la question 1 (notez que ce n'est qu'une partie de la raison).

Question 1 : Vérifiez /proc/softirqs sur le serveur, pourquoi NET_RX est-il beaucoup plus grand que NET_TX ?

L'achèvement du transfert déclenche finalement NET_RX, et non NET_TX. Donc, naturellement, vous pouvez voir plus de NET_RX en observant /proc/softirqs.

Ok, vous avez maintenant une vue d'ensemble de la façon dont le noyau envoie les paquets réseau. Ne soyez pas complaisant, les détails que nous devons connaître sont plus précieux, continuons ! !

2. Préparation au démarrage de la carte réseau

Les cartes réseau des serveurs actuels prennent généralement en charge plusieurs files d'attente. Chaque file d'attente est représentée par un RingBuffer, et la carte réseau avec plusieurs files d'attente activées aura plusieurs RingBuffers.

L'une des tâches les plus importantes lors du démarrage de la carte réseau est d'allouer et d'initialiser RingBuffer.Comprendre RingBuffer nous sera très utile pour maîtriser l'envoi plus tard. Étant donné que le sujet d'aujourd'hui est l'envoi, prenons la file d'attente de transmission comme exemple, examinons le processus réel d'allocation de RingBuffer au démarrage de la carte réseau.

Lorsque la carte réseau est démarrée, la fonction __igb_open sera appelée et le RingBuffer est alloué ici.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int __igb_open(struct net_device *netdev, bool resuming)
{
 struct igb_adapter *adapter = netdev_priv(netdev);

 //分配传输描述符数组
 err = igb_setup_all_tx_resources(adapter);

 //分配接收描述符数组
 err = igb_setup_all_rx_resources(adapter);

 //开启全部队列
 netif_tx_start_all_queues(netdev);
}

Dans la fonction __igb_open ci-dessus, appelez igb_setup_all_tx_resources pour allouer tous les RingBuffers de transmission et appelez igb_setup_all_rx_resources pour créer tous les RingBuffers de réception.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int igb_setup_all_tx_resources(struct igb_adapter *adapter)
{
 //有几个队列就构造几个 RingBuffer
 for (i = 0; i < adapter->num_tx_queues; i++) {
  igb_setup_tx_resources(adapter->tx_ring[i]);
 }
}

Le véritable processus de construction de RingBuffer est terminé dans igb_setup_tx_resources.

//file: drivers/net/ethernet/intel/igb/igb_main.c
int igb_setup_tx_resources(struct igb_ring *tx_ring)
{
 //1.申请 igb_tx_buffer 数组内存
 size = sizeof(struct igb_tx_buffer) * tx_ring->count;
 tx_ring->tx_buffer_info = vzalloc(size);

 //2.申请 e1000_adv_tx_desc DMA 数组内存
 tx_ring->size = tx_ring->count * sizeof(union e1000_adv_tx_desc);
 tx_ring->size = ALIGN(tx_ring->size, 4096);
 tx_ring->desc = dma_alloc_coherent(dev, tx_ring->size,
        &tx_ring->dma, GFP_KERNEL);

 //3.初始化队列成员
 tx_ring->next_to_use = 0;
 tx_ring->next_to_clean = 0;
}

Comme on peut le voir dans le code source ci-dessus, en fait, un RingBuffer n'a pas seulement un tableau de file d'attente en anneau, mais deux.

1) Tableau igb_tx_buffer : Ce tableau est utilisé par le noyau et appliqué via vzalloc. 2) Tableau e1000_adv_tx_desc : Ce tableau est utilisé par le matériel de la carte réseau. Le matériel peut accéder directement à cette mémoire via DMA et l'allouer via dma_alloc_coherent.

Pour le moment, il n'y a aucun lien entre eux. Lors de l'envoi futur, les pointeurs à la même position dans les deux réseaux en anneau pointeront tous vers le même skb. De cette manière, le noyau et le matériel peuvent accéder conjointement aux mêmes données, le noyau écrit des données dans skb et le matériel de la carte réseau est responsable de leur envoi.

Enfin, appelez netif_tx_start_all_queues pour démarrer la file d'attente. De plus, la fonction de traitement igb_msix_ring pour les interruptions matérielles est en fait enregistrée dans __igb_open.

Trois, acceptez de créer un nouveau socket

Avant d'envoyer des données, nous avons souvent besoin d'un socket qui a déjà établi une connexion.

Prenons l'exemple de l'acceptation mentionnée dans le code source de la microforme du serveur. Après acceptation, le processus créera un nouveau socket, puis le mettra dans la liste des fichiers ouverts du processus en cours, qui est spécialement utilisé pour communiquer avec le client correspondant. .communication.

En supposant que le processus serveur a établi deux connexions avec le client via accept, examinons brièvement la relation entre ces deux connexions et le processus.

Le schéma structurel plus spécifique de l'objet noyau socket représentant une connexion est le suivant.

Afin d'éviter d'être submergé, le processus détaillé d'acceptation du code source ne sera pas présenté ici. Si vous êtes intéressé, veuillez vous reporter à "Illustration | Démystification approfondie de la façon dont epoll réalise le multiplexage des E/S !" " . La première partie de l'article.

Aujourd'hui, nous nous concentrons toujours sur le processus d'envoi de données.

4. L'envoi de données commence vraiment

4.1 envoyer l'implémentation de l'appel système

Le code source de l'appel système send se trouve dans le fichier net/socket.c. Dans cet appel système, l'appel système sendto est en fait utilisé en interne. Bien que toute la chaîne d'appel ne soit pas courte, elle ne fait en fait que deux choses simples,

  • La première consiste à trouver le véritable socket dans le noyau, et les adresses de fonction des différentes piles de protocoles sont enregistrées dans cet objet.
  • La seconde consiste à construire un objet struct msghdr et à y placer toutes les données transmises par l'utilisateur, telles que l'adresse du tampon, la longueur des données, etc.

Le reste est transmis à la couche suivante, la fonction inet_sendmsg dans la pile de protocoles, où l'adresse de la fonction inet_sendmsg est trouvée via le membre ops dans l'objet noyau socket. Le processus général est illustré sur la figure.

Avec la compréhension ci-dessus, il nous sera beaucoup plus facile de regarder le code source. Le code source est le suivant :

//file: net/socket.c
SYSCALL_DEFINE4(send, int, fd, void __user *, buff, size_t, len,
  unsigned int, flags)
{
 return sys_sendto(fd, buff, len, flags, NULL, 0);
}

SYSCALL_DEFINE6(......)
{
 //1.根据 fd 查找到 socket
 sock = sockfd_lookup_light(fd, &err, &fput_needed);

 //2.构造 msghdr
 struct msghdr msg;
 struct iovec iov;

 iov.iov_base = buff;
 iov.iov_len = len;
 msg.msg_iovlen = 1;

 msg.msg_iov = &iov;
 msg.msg_flags = flags;
 ......

 //3.发送数据
 sock_sendmsg(sock, &msg, len);
}

Comme le montre le code source, les fonctions send et sendto que nous utilisons en mode utilisateur sont en fait implémentées par l'appel système sendto. send est juste un moyen plus simple d'appeler encapsulé pour plus de commodité.

Dans l'appel système sendto, l'objet réel du noyau de socket est d'abord recherché en fonction du numéro de handle de socket transmis par l'utilisateur. Ensuite, placez les paramètres buff, len, flag et autres demandés par l'utilisateur dans un objet struct msghdr.

Puis appelé sock_sendmsg => __sock_sendmsg ==> __sock_sendmsg_nosec. Dans __sock_sendmsg_nosec, l'appel entrera dans la pile de protocoles à partir de l'appel système, regardons son code source.

//file: net/socket.c
static inline int __sock_sendmsg_nosec(...)
{
 ......
 return sock->ops->sendmsg(iocb, sock, msg, size);
}

À travers le diagramme de structure d'objet du noyau de socket dans la troisième section, nous pouvons voir que ce qui est appelé ici est sock->ops->sendmsg, et inet_sendmsg est en fait exécuté. Cette fonction est une fonction d'émission générale fournie par la famille de protocoles AF_INET.

4.2 Traitement de la couche transport

1) Copie de la couche transport

Après avoir entré la pile de protocoles inet_sendmsg, le noyau trouvera alors la fonction d'envoi de protocole spécifique sur le socket. Pour le protocole TCP, c'est tcp_sendmsg (également trouvé via l'objet noyau socket).

Dans cette fonction, le noyau demandera une mémoire skb en mode noyau et y copiera les données à envoyer par l'utilisateur. Notez que l'envoi peut ne pas démarrer à ce moment. Si la condition d'envoi n'est pas remplie, il est probable que l'appel reviendra directement. Le processus approximatif est comme indiqué sur la figure :

Regardons le code source de la fonction inet_sendmsg.

//file: net/ipv4/af_inet.c
int inet_sendmsg(......)
{
 ......
 return sk->sk_prot->sendmsg(iocb, sk, msg, size);
}

Dans cette fonction, la fonction d'envoi du protocole spécifique sera appelée. Reportez-vous également au diagramme de structure d'objet du noyau de socket dans la troisième section, nous voyons que pour le socket sous le protocole TCP, sk->sk_prot->sendmsg pointe vers tcp_sendmsg (pour UPD, c'est udp_sendmsg).

La fonction tcp_sendmsg est relativement longue, alors regardons-la plusieurs fois. regarde ça d'abord

//file: net/ipv4/tcp.c
int tcp_sendmsg(...)
{
 while(...){
  while(...){
   //获取发送队列
   skb = tcp_write_queue_tail(sk);

   //申请skb 并拷贝
   ......
  }
 }
}

//file: include/net/tcp.h
static inline struct sk_buff *tcp_write_queue_tail(const struct sock *sk)
{
 return skb_peek_tail(&sk->sk_write_queue);
}

Comprendre l'appel de tcp_write_queue_tail sur socket est un prérequis pour comprendre l'envoi. Comme indiqué ci-dessus, cette fonction consiste à obtenir le dernier skb dans la file d'attente d'envoi du socket. skb est l'abréviation de struct sk_buff object, et la file d'attente d'envoi de l'utilisateur est une liste chaînée composée de cet objet.

Regardons d'autres parties de tcp_sendmsg.

//file: net/ipv4/tcp.c
int tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
  size_t size)
{
 //获取用户传递过来的数据和标志
 iov = msg->msg_iov; //用户数据地址
 iovlen = msg->msg_iovlen; //数据块数为1
 flags = msg->msg_flags; //各种标志

 //遍历用户层的数据块
 while (--iovlen >= 0) {

  //待发送数据块的地址
  unsigned char __user *from = iov->iov_base;

  while (seglen > 0) {

   //需要申请新的 skb
   if (copy <= 0) {

    //申请 skb,并添加到发送队列的尾部
    skb = sk_stream_alloc_skb(sk,
         select_size(sk, sg),
         sk->sk_allocation);

    //把 skb 挂到socket的发送队列上
    skb_entail(sk, skb);
   }

   // skb 中有足够的空间
   if (skb_availroom(skb) > 0) {
    //拷贝用户空间的数据到内核空间,同时计算校验和
    //from是用户空间的数据地址 
    skb_add_data_nocache(sk, skb, from, copy);
   } 
   ......

Cette fonction est relativement longue, mais la logique n'est pas compliquée. Parmi eux, msg->msg_iov stocke le tampon des données à envoyer dans la mémoire du mode utilisateur. Ensuite, demandez la mémoire du noyau dans l'état du noyau, comme skb, et copiez les données de la mémoire utilisateur dans la mémoire de l'état du noyau. Cela impliquera la surcharge d'une ou plusieurs copies de mémoire .

Quant au moment où le noyau envoie réellement le fichier skb. Certains jugements seront effectués dans tcp_sendmsg.

//file: net/ipv4/tcp.c
int tcp_sendmsg(...)
{
 while(...){
  while(...){
   //申请内核内存并进行拷贝

   //发送判断
   if (forced_push(tp)) {
    tcp_mark_push(tp, skb);
    __tcp_push_pending_frames(sk, mss_now, TCP_NAGLE_PUSH);
   } else if (skb == tcp_send_head(sk))
    tcp_push_one(sk, mss_now);  
   }
   continue;
  }
 }
}

Ce n'est que lorsque forced_push(tp) ou skb == tcp_send_head(sk) est satisfait que le noyau commencera réellement à envoyer des paquets. Parmi eux, forced_push(tp) juge si les données non envoyées ont dépassé la moitié de la fenêtre maximale.

Si les conditions ne sont pas remplies, les données que l'utilisateur veut envoyer cette fois-ci sont simplement copiées dans le noyau et le travail est terminé !

2) Envoi de la couche transport

En supposant que les conditions d'envoi du noyau sont maintenant remplies, suivons le processus d'envoi réel. Pour les fonctions de la section précédente, lorsque les conditions d'envoi réelles sont remplies, que __tcp_push_pending_frames ou tcp_push_one soit appelé, il exécutera réellement tcp_write_xmit à la fin.

On regarde donc directement depuis tcp_write_xmit, cette fonction gère le contrôle de congestion de la couche transport et le travail lié à la fenêtre glissante. Lorsque les exigences de fenêtre sont remplies, définissez l'en-tête TCP et transmettez skb à la couche réseau inférieure pour traitement.

Regardons le code source de tcp_write_xmit.

//file: net/ipv4/tcp_output.c
static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,
      int push_one, gfp_t gfp)
{
 //循环获取待发送 skb
 while ((skb = tcp_send_head(sk))) 
 {
  //滑动窗口相关
  cwnd_quota = tcp_cwnd_test(tp, skb);
  tcp_snd_wnd_test(tp, skb, mss_now);
  tcp_mss_split_point(...);
  tso_fragment(sk, skb, ...);
  ......

  //真正开启发送
  tcp_transmit_skb(sk, skb, 1, gfp);
 }
}

Vous pouvez voir que la fenêtre glissante et le contrôle de congestion que nous avons appris dans le protocole réseau sont complétés dans cette fonction, et cette partie ne sera pas trop développée. Les étudiants intéressés peuvent trouver ce code source à lire par eux-mêmes. Nous ne regardons que le processus principal d'envoi aujourd'hui, puis nous arrivons à tcp_transmit_skb.

//file: net/ipv4/tcp_output.c
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,
    gfp_t gfp_mask)
{
 //1.克隆新 skb 出来
 if (likely(clone_it)) {
  skb = skb_clone(skb, gfp_mask);
  ......
 }

 //2.封装 TCP 头
 th = tcp_hdr(skb);
 th->source  = inet->inet_sport;
 th->dest  = inet->inet_dport;
 th->window  = ...;
 th->urg   = ...;
 ......

 //3.调用网络层发送接口
 err = icsk->icsk_af_ops->queue_xmit(skb, &inet->cork.fl);
}

La première chose à faire est de cloner d'abord un nouveau skb. Ici, nous allons nous concentrer sur la raison pour laquelle nous devons copier un skb ?

C'est parce que le skb appelle la couche réseau plus tard, et lorsque la carte réseau est finalement envoyée, le skb sera libéré. Et nous savons que le protocole TCP prend en charge la retransmission perdue, et ce skb ne peut pas être supprimé avant de recevoir l'ACK de l'autre partie. Ainsi, la méthode du noyau est qu'à chaque fois que la carte réseau est appelée pour envoyer, ce qui est réellement transmis est une copie de skb. Attendez que l'ACK soit reçu avant de supprimer réellement.

La deuxième chose est de modifier l'en-tête TCP dans skb et de définir l'en-tête TCP en fonction de la situation réelle. Voici une petite astuce à introduire, skb contient en fait tous les en-têtes du protocole réseau. Lors de la définition de l'en-tête TCP, pointez simplement le pointeur sur la position appropriée de skb. Lors de la définition ultérieure de l'en-tête IP, déplacez simplement le pointeur pour éviter une application et une copie fréquentes de la mémoire, ce qui est très efficace.

tcp_transmit_skb est la dernière étape de l'envoi de données au niveau de la couche transport, puis il peut entrer dans la couche réseau pour la couche d'opérations suivante. L'interface d'envoi icsk->icsk_af_ops->queue_xmit() fournie par la couche réseau est appelée.

Dans le code source ci-dessous, nous savons que queue_xmit pointe en fait sur la fonction ip_queue_xmit.

//file: net/ipv4/tcp_ipv4.c
const struct inet_connection_sock_af_ops ipv4_specific = {
 .queue_xmit    = ip_queue_xmit,
 .send_check    = tcp_v4_send_check,
 ...
}

Depuis, les travaux de la couche transport ont été achevés. Les données quittent la couche transport et entreront ensuite dans l'implémentation du noyau au niveau de la couche réseau.

4.3 Traitement d'envoi de la couche réseau

L'implémentation de l'envoi dans la couche réseau du noyau Linux se trouve dans le fichier net/ipv4/ip_output.c. Le ip_queue_xmit appelé par la couche de transport est également ici. (On peut également voir d'après le nom du fichier qu'il est entré dans la couche IP et que le nom du fichier source est passé de tcp_xxx à ip_xxx.)

Dans la couche réseau, il gère principalement plusieurs tâches telles que la recherche d'éléments de routage, la définition d'en-tête IP, le filtrage netfilter, la segmentation skb (si elle est supérieure à MTU), etc. Après avoir traité ces tâches, il sera remis au voisin inférieur sous-système de traitement.

Examinons le code source de la fonction d'entrée de la couche réseau ip_queue_xmit :

//file: net/ipv4/ip_output.c
int ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
{
 //检查 socket 中是否有缓存的路由表
 rt = (struct rtable *)__sk_dst_check(sk, 0);
 if (rt == NULL) {
  //没有缓存则展开查找
  //则查找路由项, 并缓存到 socket 中
  rt = ip_route_output_ports(...);
  sk_setup_caps(sk, &rt->dst);
 }

 //为 skb 设置路由表
 skb_dst_set_noref(skb, &rt->dst);

 //设置 IP header
 iph = ip_hdr(skb);
 iph->protocol = sk->sk_protocol;
 iph->ttl      = ip_select_ttl(inet, &rt->dst);
 iph->frag_off = ...;

 //发送
 ip_local_out(skb);
}

ip_queue_xmit a atteint la couche réseau. Dans cette fonction, nous voyons la fonction recherche d'élément de routage liée à la couche réseau. S'il est trouvé, il sera défini sur skb (s'il n'y a pas de route, il signalera directement une erreur et retournera ).

Sous Linux, vous pouvez voir la configuration de routage de votre machine locale via la commande route.

Dans la table de routage, vous pouvez savoir par quelle Iface (carte réseau) et quelle passerelle (carte réseau) un réseau de destination doit envoyer. Une fois la recherche trouvée, elle est mise en cache sur le socket et la prochaine fois que les données sont envoyées, il n'est pas nécessaire de la vérifier.

Ensuite, mettez également l'adresse de la table de routage dans skb.

//file: include/linux/skbuff.h
struct sk_buff {
 //保存了一些路由相关信息
 unsigned long  _skb_refdst;
}

L'étape suivante consiste à localiser la position de l'en-tête IP dans le skb, puis à commencer à définir l'en-tête IP conformément à la spécification du protocole.

Passez ensuite à l'étape suivante via ip_local_out.

//file: net/ipv4/ip_output.c  
int ip_local_out(struct sk_buff *skb)
{
 //执行 netfilter 过滤
 err = __ip_local_out(skb);

 //开始发送数据
 if (likely(err == 1))
  err = dst_output(skb);
 ......

Dans ip_local_out => __ip_local_out => nf_hook effectuera le filtrage netfilter. Si vous utilisez iptables pour configurer certaines règles, alors ici vérifiera si les règles sont respectées. Si vous définissez une règle netfilter très compliquée, cette fonction entraînera une augmentation considérable de la surcharge du processeur de votre processus .

N'en parlez toujours pas beaucoup, continuez simplement à parler du processus lié à l'envoi de dst_output.

//file: include/net/dst.h
static inline int dst_output(struct sk_buff *skb)
{
 return skb_dst(skb)->output(skb);
}

Cette fonction trouve la table de routage (entrée dst) vers ce skb puis appelle la méthode de sortie de la table de routage. Il s'agit à nouveau d'un pointeur de fonction, pointant vers la méthode ip_output.

//file: net/ipv4/ip_output.c
int ip_output(struct sk_buff *skb)
{
 //统计
 .....

 //再次交给 netfilter,完毕后回调 ip_finish_output
 return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,
    ip_finish_output,
    !(IPCB(skb)->flags & IPSKB_REROUTED));
}

Effectuez un travail statistique simple dans ip_output et effectuez à nouveau le filtrage netfilter. Rappelez ip_finish_output après le filtrage.

//file: net/ipv4/ip_output.c
static int ip_finish_output(struct sk_buff *skb)
{
 //大于 mtu 的话就要进行分片了
 if (skb->len > ip_skb_dst_mtu(skb) && !skb_is_gso(skb))
  return ip_fragment(skb, ip_finish_output2);
 else
  return ip_finish_output2(skb);
}

Dans ip_finish_output, nous voyons que si les données sont plus grandes que le MTU, la fragmentation sera effectuée.

La taille réelle du MTU est déterminée par la découverte du MTU et la trame Ethernet est de 1 500 octets. Au début, l'équipe QQ essayait de contrôler la taille de ses paquets de données pour qu'elle soit inférieure à la MTU et optimisait ainsi les performances du réseau. Parce que la fragmentation apportera deux problèmes : 1. Un traitement de segmentation supplémentaire est requis, ce qui entraîne une surcharge de performances supplémentaire. 2. Tant qu'un fragment est perdu, le paquet entier doit être retransmis. Par conséquent, éviter la fragmentation élimine non seulement la surcharge de fragmentation, mais réduit également considérablement le taux de retransmission.

Dans ip_finish_output2, le processus d'envoi final entrera dans la couche suivante, le sous-système voisin.

//file: net/ipv4/ip_output.c
static inline int ip_finish_output2(struct sk_buff *skb)
{
 //根据下一跳 IP 地址查找邻居项,找不到就创建一个
 nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);  
 neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
 if (unlikely(!neigh))
  neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);

 //继续向下层传递
 int res = dst_neigh_output(dst, neigh, skb);
}

4.4 Sous-système de voisinage

Le sous-système voisin est un système situé entre la couche réseau et la couche liaison de données. Sa fonction est de fournir une encapsulation pour la couche réseau, de sorte que la couche réseau n'a pas besoin de se soucier des informations d'adresse de la couche inférieure, et de laisser la couche inférieure décide à quelle adresse MAC envoyer.

Et ce sous-système voisin n'est pas situé dans le répertoire net/ipv4/ de la pile de protocoles, mais dans net/core/neighbor.c. Parce que ce module est requis pour IPv4 et IPv6.

Dans le sous-système voisin, il s'agit principalement de trouver ou de créer une entrée voisine. Lors de la création d'une entrée voisine, une requête arp réelle peut être envoyée. Ensuite, encapsulez l'en-tête MAC et transmettez le processus d'envoi au sous-système de périphérique réseau de niveau inférieur. Le processus général est illustré sur la figure.

Après avoir compris le processus général, revenons sur le code source. __ipv4_neigh_lookup_noref est appelé dans le code source de ip_finish_output2 dans la section ci-dessus. Il recherche dans le cache arp et son deuxième paramètre est l'information IP du saut suivant de la route.

//file: include/net/arp.h
extern struct neigh_table arp_tbl;
static inline struct neighbour *__ipv4_neigh_lookup_noref(
 struct net_device *dev, u32 key)
{
 struct neigh_hash_table *nht = rcu_dereference_bh(arp_tbl.nht);

 //计算 hash 值,加速查找
 hash_val = arp_hashfn(......);
 for (n = rcu_dereference_bh(nht->hash_buckets[hash_val]);
   n != NULL;
   n = rcu_dereference_bh(n->next)) {
  if (n->dev == dev && *(u32 *)n->primary_key == key)
   return n;
 }
}

S'il n'est pas trouvé, appelez __neigh_create pour créer un voisin.

//file: net/core/neighbour.c
struct neighbour *__neigh_create(......)
{
 //申请邻居表项
 struct neighbour *n1, *rc, *n = neigh_alloc(tbl, dev);

 //构造赋值
 memcpy(n->primary_key, pkey, key_len);
 n->dev = dev;
 n->parms->neigh_setup(n);

 //最后添加到邻居 hashtable 中
 rcu_assign_pointer(nht->hash_buckets[hash_val], n);
 ......

Après avoir reçu l'entrée du voisin, il n'a toujours pas la capacité d'envoyer des paquets IP pour le moment, car l'adresse MAC de destination n'a pas encore été obtenue. Appelez dst_neigh_output pour continuer à transmettre skb.

//file: include/net/dst.h
static inline int dst_neigh_output(struct dst_entry *dst, 
     struct neighbour *n, struct sk_buff *skb)
{
 ......
 return n->output(n, skb);
}

La sortie d'appel pointe en fait vers neigh_resolve_output. À l'intérieur de cette fonction, il est possible d'émettre une requête réseau arp.

//file: net/core/neighbour.c
int neigh_resolve_output(){

 //注意:这里可能会触发 arp 请求
 if (!neigh_event_send(neigh, skb)) {

  //neigh->ha 是 MAC 地址
  dev_hard_header(skb, dev, ntohs(skb->protocol),
           neigh->ha, NULL, skb->len);
  //发送
  dev_queue_xmit(skb);
 }
}

Après avoir obtenu l'adresse MAC matérielle, vous pouvez encapsuler l'en-tête MAC de skb. Enfin, appelez dev_queue_xmit pour transmettre le skb au sous-système de périphérique réseau Linux.

4.5 Sous-système d'équipement de réseau

Le sous-système voisin entre dans le sous-système de périphérique réseau via dev_queue_xmit.

//file: net/core/dev.c 
int dev_queue_xmit(struct sk_buff *skb)
{
 //选择发送队列
 txq = netdev_pick_tx(dev, skb);

 //获取与此队列关联的排队规则
 q = rcu_dereference_bh(txq->qdisc);

 //如果有队列,则调用__dev_xmit_skb 继续处理数据
 if (q->enqueue) {
  rc = __dev_xmit_skb(skb, q, dev, txq);
  goto out;
 }

 //没有队列的是回环设备和隧道设备
 ......
}

Dans la deuxième section du chapitre d'ouverture, nous avons dit dans la préparation du démarrage de la carte réseau que la carte réseau a plusieurs files d'attente d'envoi (en particulier la carte réseau actuelle). L'appel ci-dessus à la fonction netdev_pick_tx consiste à sélectionner une file d'attente à envoyer.

La sélection de la file d'attente d'envoi netdev_pick_tx est affectée par des configurations telles que XPS, et il existe également un cache, qui est également un ensemble de logique petite et compliquée. Ici, nous nous concentrons uniquement sur deux logiques : premièrement, la configuration XPS de l'utilisateur sera obtenue, sinon elle sera calculée automatiquement. Voir netdev_pick_tx => __netdev_pick_tx pour le code.

//file: net/core/flow_dissector.c
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
 //获取 XPS 配置
 int new_index = get_xps_queue(dev, skb);

 //自动计算队列
 if (new_index < 0)
  new_index = skb_tx_hash(dev, skb);}

Récupérez ensuite le qdisc associé à cette file d'attente. Le type qdisc peut être vu via la commande tc sous linux, par exemple, il s'agit d'un disque mq sur l'une de mes machines à cartes réseau multi-files d'attente.

#tc qdisc
qdisc mq 0: dev eth0 root

La plupart des périphériques ont des files d'attente (à l'exception des périphériques de bouclage et de tunnel), nous allons donc maintenant passer à __dev_xmit_skb.

//file: net/core/dev.c
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,
     struct net_device *dev,
     struct netdev_queue *txq)
{
 //1.如果可以绕开排队系统
 if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
     qdisc_run_begin(q)) {
  ......
 }

 //2.正常排队
 else {

  //入队
  q->enqueue(skb, q)

  //开始发送
  __qdisc_run(q);
 }
}

Il existe deux situations dans le code ci-dessus, l'une est que le système de file d'attente peut être contourné et l'autre est une file d'attente normale. Nous ne regardons que le deuxième cas.

Appelez d'abord q->enqueue pour ajouter skb à la file d'attente. Appelez ensuite __qdisc_run pour commencer l'envoi.

//file: net/sched/sch_generic.c
void __qdisc_run(struct Qdisc *q)
{
 int quota = weight_p;

 //循环从队列取出一个 skb 并发送
 while (qdisc_restart(q)) {
  
  // 如果发生下面情况之一,则延后处理:
  // 1. quota 用尽
  // 2. 其他进程需要 CPU
  if (--quota <= 0 || need_resched()) {
   //将触发一次 NET_TX_SOFTIRQ 类型 softirq
   __netif_schedule(q);
   break;
  }
 }
}

Dans le code ci-dessus, nous voyons que la boucle while récupère continuellement skb de la file d'attente et les envoie. Notez que ce temps occupe en fait le temps d'état système (sy) du processus utilisateur. Ce n'est que lorsque le quota est épuisé ou que d'autres processus ont besoin du CPU que l'interruption logicielle sera déclenchée pour envoyer.

C'est donc la deuxième raison pour laquelle NET_RX est généralement beaucoup plus grand que NET_TX lors de la visualisation de /proc/softirqs sur un serveur général . Pour la lecture, il doit passer par l'interruption logicielle NET_RX, et pour l'envoi, l'interruption logicielle n'est autorisée que lorsque le quota d'état du système est épuisé.

Concentrons-nous sur qdisc_restart et continuons à voir le processus d'envoi.

static inline int qdisc_restart(struct Qdisc *q)
{
 //从 qdisc 中取出要发送的 skb
 skb = dequeue_skb(q);
 ...

 return sch_direct_xmit(skb, q, dev, txq, root_lock);
}

qdisc_restart prend un skb de la file d'attente et appelle sch_direct_xmit pour continuer à envoyer.

//file: net/sched/sch_generic.c
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,
   struct net_device *dev, struct netdev_queue *txq,
   spinlock_t *root_lock)
{
 //调用驱动程序来发送数据
 ret = dev_hard_start_xmit(skb, dev, txq);
}

4.6 Planification des interruptions logicielles

En 4.5, nous avons vu que si le CPU dans l'état système n'est pas suffisant pour envoyer des paquets réseau, il appellera __netif_schedule pour déclencher une interruption logicielle. Cette fonction entrera __netif_reschedule, qui émettra en fait une interruption logicielle de type NET_TX_SOFTIRQ.

L'interruption logicielle est exécutée par le thread du noyau, qui entrera dans la fonction net_tx_action, dans laquelle la file d'attente d'envoi peut être obtenue, et enfin la fonction d'entrée dev_hard_start_xmit dans le pilote est appelée.

//file: net/core/dev.c
static inline void __netif_reschedule(struct Qdisc *q)
{
 sd = &__get_cpu_var(softnet_data);
 q->next_sched = NULL;
 *sd->output_queue_tailp = q;
 sd->output_queue_tailp = &q->next_sched;

 ......
 raise_softirq_irqoff(NET_TX_SOFTIRQ);
}

Dans cette fonction, la file d'attente de données à envoyer est définie dans le softnet_data accessible par le softirq, et ajoutée à la output_queue. Ensuite, une interruption logicielle de type NET_TX_SOFTIRQ est déclenchée. (T signifie émission de transmission)

Je n'entrerai pas dans les détails du code d'entrée de softirq ici. Les étudiants intéressés peuvent se référer à la section 3.2 de l'article "Processus illustré de réception de paquets réseau Linux" - processus de thread du noyau ksoftirqd softirq.

On part directement de la fonction callback net_tx_action enregistrée par NET_TX_SOFTIRQ softirq. Une fois que le processus en mode utilisateur a déclenché l'interruption logicielle, un thread du noyau d'interruption logicielle exécutera net_tx_action.

Gardez à l'esprit que le CPU consommé par l'envoi de données sera désormais affiché dans si, et que le temps système du processus utilisateur ne sera pas consommé .

//file: net/core/dev.c
static void net_tx_action(struct softirq_action *h)
{
 //通过 softnet_data 获取发送队列
 struct softnet_data *sd = &__get_cpu_var(softnet_data);

 // 如果 output queue 上有 qdisc
 if (sd->output_queue) {

  // 将 head 指向第一个 qdisc
  head = sd->output_queue;

  //遍历 qdsics 列表
  while (head) {
   struct Qdisc *q = head;
   head = head->next_sched;

   //发送数据
   qdisc_run(q);
  }
 }
}

L'interruption logicielle obtiendra softnet_data ici. Nous avons vu précédemment que le mode noyau du processus écrivait la file d'attente d'envoi dans la file d'attente de sortie de softnet_data lors de l'appel de __netif_reschedule. L'interruption logicielle parcourt sd->output_queue pour envoyer des trames de données.

Regardons qdisc_run, qui, comme le mode utilisateur de processus, appellera également __qdisc_run.

//file: include/net/pkt_sched.h
static inline void qdisc_run(struct Qdisc *q)
{
 if (qdisc_run_begin(q))
  __qdisc_run(q);
}

Ensuite, la même chose consiste à entrer qdisc_restart => sch_direct_xmit jusqu'à ce que la fonction du pilote dev_hard_start_xmit.

4.7 envoi de pilote de carte réseau igb

Comme nous l'avons vu précédemment, que ce soit pour l'état du noyau du processus utilisateur ou pour le contexte d'interruption logicielle, la fonction dev_hard_start_xmit dans le sous-système de périphérique réseau sera appelée. Dans cette fonction, la fonction d'envoi igb_xmit_frame dans le pilote sera appelée.

Dans la fonction du pilote, le skb sera suspendu au RingBuffer.Après l'appel du pilote, le paquet de données sera effectivement envoyé depuis la carte réseau.

Jetons un coup d'œil au code source réel :

//file: net/core/dev.c
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
   struct netdev_queue *txq)
{
 //获取设备的回调函数集合 ops
 const struct net_device_ops *ops = dev->netdev_ops;

 //获取设备支持的功能列表
 features = netif_skb_features(skb);

 //调用驱动的 ops 里面的发送回调函数 ndo_start_xmit 将数据包传给网卡设备
 skb_len = skb->len;
 rc = ops->ndo_start_xmit(skb, dev);
}

Parmi eux, ndo_start_xmit est une fonction à implémenter par le pilote de la carte réseau, qui est définie dans net_device_ops.

//file: include/linux/netdevice.h
struct net_device_ops {
 netdev_tx_t  (*ndo_start_xmit) (struct sk_buff *skb,
         struct net_device *dev);

}

Dans le code source du pilote de la carte réseau igb, nous l'avons trouvé.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static const struct net_device_ops igb_netdev_ops = {
 .ndo_open  = igb_open,
 .ndo_stop  = igb_close,
 .ndo_start_xmit  = igb_xmit_frame, 
 ...
};

C'est-à-dire que pour ndo_start_xmit défini par la couche de dispositif réseau, la fonction d'implémentation d'igb est igb_xmit_frame. Cette fonction est attribuée lorsque le pilote de la carte réseau est initialisé. Pour le processus d'initialisation spécifique, voir la section 2.4 de l'article "Processus illustré de réception de paquets réseau Linux" , initialisation du pilote de carte réseau.

Ainsi, lors de l'appel de ops-> ndo_start_xmit au niveau de la couche de périphérique réseau ci-dessus, il entrera en fait dans la fonction igb_xmit_frame. Entrons dans cette fonction pour voir comment fonctionne le pilote.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static netdev_tx_t igb_xmit_frame(struct sk_buff *skb,
      struct net_device *netdev)
{
 ......
 return igb_xmit_frame_ring(skb, igb_tx_queue_mapping(adapter, skb));
}

netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb,
    struct igb_ring *tx_ring)
{
 //获取TX Queue 中下一个可用缓冲区信息
 first = &tx_ring->tx_buffer_info[tx_ring->next_to_use];
 first->skb = skb;
 first->bytecount = skb->len;
 first->gso_segs = 1;

 //igb_tx_map 函数准备给设备发送的数据。
 igb_tx_map(tx_ring, first, hdr_len);
}

Ici, un élément est extrait du RingBuffer de la file d'attente d'envoi de la carte réseau, et le skb est attaché à l'élément.

La fonction igb_tx_map gère le mappage des données skb dans une zone mémoire DMA accessible par la carte réseau.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static void igb_tx_map(struct igb_ring *tx_ring,
      struct igb_tx_buffer *first,
      const u8 hdr_len)
{
 //获取下一个可用描述符指针
 tx_desc = IGB_TX_DESC(tx_ring, i);

 //为 skb->data 构造内存映射,以允许设备通过 DMA 从 RAM 中读取数据
 dma = dma_map_single(tx_ring->dev, skb->data, size, DMA_TO_DEVICE);

 //遍历该数据包的所有分片,为 skb 的每个分片生成有效映射
 for (frag = &skb_shinfo(skb)->frags[0];; frag++) {

  tx_desc->read.buffer_addr = cpu_to_le64(dma);
  tx_desc->read.cmd_type_len = ...;
  tx_desc->read.olinfo_status = 0;
 }

 //设置最后一个descriptor
 cmd_type |= size | IGB_TXD_DCMD;
 tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type);

 /* Force memory writes to complete before letting h/w know there
  * are new descriptors to fetch
  */
 wmb();
}

Lorsque tous les descripteurs requis ont été construits et que toutes les données du skb ont été mappées aux adresses DMA, le pilote passe à l'étape finale, déclenchant l'envoi réel.

4.8 Envoyer une interruption matérielle complète

Lorsque les données sont envoyées, le travail n'est pas terminé. Parce que la mémoire n'a pas été nettoyée. Lorsque la transmission est terminée, le périphérique de la carte réseau déclenchera une interruption matérielle pour libérer la mémoire.

Dans les sections 3.1 et 3.2 de l'article "Processus illustré de réception de paquets réseau Linux" , nous décrivons en détail le processus de traitement des interruptions matérielles et logicielles.

Lors de l'interruption matérielle d'achèvement de l'envoi, le nettoyage de la mémoire RingBuffer sera effectué, comme indiqué sur la figure.

Examinez le code source de l'interruption logicielle déclenchée par l'interruption matérielle.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static inline void ____napi_schedule(...){
 list_add_tail(&napi->poll_list, &sd->poll_list);
 __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

Il y a un détail très intéressant ici, que l'interruption matérielle soit due au fait qu'il y a des données à recevoir ou à la notification d'achèvement d'envoi, l' interruption logicielle déclenchée à partir de l'interruption matérielle est NET_RX_SOFTIRQ . Nous l'avons dit dans la première section, c'est l'une des raisons pour lesquelles RX est supérieur à TX dans les statistiques d'interruption logicielle.

Ok, passons à la fonction de rappel igb_poll de l'interruption logicielle. Dans cette fonction, nous avons remarqué qu'il y a une ligne igb_clean_tx_irq, voir le code source :

//file: drivers/net/ethernet/intel/igb/igb_main.c
static int igb_poll(struct napi_struct *napi, int budget)
{
 //performs the transmit completion operations
 if (q_vector->tx.ring)
  clean_complete = igb_clean_tx_irq(q_vector);
 ...
}

Voyons ce que fait igb_clean_tx_irq lorsque la transmission est terminée.

//file: drivers/net/ethernet/intel/igb/igb_main.c
static bool igb_clean_tx_irq(struct igb_q_vector *q_vector)
{
 //free the skb
 dev_kfree_skb_any(tx_buffer->skb);

 //clear tx_buffer data
 tx_buffer->skb = NULL;
 dma_unmap_len_set(tx_buffer, len, 0);

 // clear last DMA location and unmap remaining buffers */
 while (tx_desc != eop_desc) {
 }
}

Ce n'est rien de plus que nettoyer le skb, libérer le mappage DMA et ainsi de suite. À ce stade, le transfert est pratiquement terminé.

Pourquoi est-ce que je dis qu'il est fondamentalement achevé, pas complètement achevé ? Parce que la couche de transport doit assurer la fiabilité, skb n'a pas été supprimé. Il ne sera pas supprimé tant qu'il n'aura pas reçu l'ACK de l'autre partie. À ce moment-là, il sera complètement envoyé.

enfin

Utilisez une image pour résumer l'ensemble du processus d'envoi

Après avoir compris l'ensemble du processus d'envoi, revenons en arrière et passons en revue les questions mentionnées au début.

1. Lorsque nous surveillons le CPU consommé par le noyau envoyant des données, devons-nous regarder sy ou si ?

Lors du processus d'envoi de paquets réseau, le processus utilisateur (dans l'état du noyau) effectue la majeure partie du travail, même l'appel du pilote. Une interruption logicielle n'est initiée qu'avant que le processus en mode noyau ne soit interrompu. Pendant le processus d'envoi, la majeure partie (90 %) de la surcharge est consommée en mode noyau du processus utilisateur.

L'interruption logicielle (type NET_TX) n'est déclenchée que dans quelques cas et est envoyée par le processus du noyau d'interruption logicielle ksoftirqd.

Par conséquent, lors de la surveillance de la surcharge du processeur causée par les E/S réseau vers le serveur, nous ne devons pas seulement examiner si, mais également prendre en considération si et sy.

2. Vérifiez /proc/softirqs sur le serveur, pourquoi NET_RX est-il beaucoup plus grand que NET_TX ?

Auparavant, je pensais que NET_RX était lu et que NET_TX était transmis. Pour un serveur qui non seulement reçoit les demandes des utilisateurs, mais les renvoie également aux utilisateurs. Les nombres de ces deux pièces devraient être à peu près les mêmes, au moins il n'y aura pas de différence d'ordre de grandeur. Mais en fait, un des serveurs de Fei Ge ressemble à ça :

Après l'analyse du code source d'aujourd'hui, il a été constaté qu'il y a deux raisons à ce problème.

La première raison est que lorsque la transmission de données est terminée, le pilote est averti de l'achèvement de la transmission par une interruption matérielle. Cependant, que l'interruption matérielle ait une réception de données ou l'achèvement de l'envoi, l'interruption logicielle déclenchée est NET_RX_SOFTIRQ, et non NET_TX_SOFTIRQ.

La deuxième raison est que pour la lecture, tout doit passer par l'interruption logicielle NET_RX, et tous passent par le processus du noyau ksoftirqd. Pour l'envoi, la majeure partie du travail est traitée en mode noyau de processus utilisateur, et ce n'est que lorsque le quota du mode système est épuisé que NET_TX sera envoyé pour laisser passer l'interruption logicielle.

Sur la base des deux raisons ci-dessus, il n'est pas difficile de comprendre que NET_RX est beaucoup plus grand que NET_TX sur la machine.

3. Quelles opérations de copie de mémoire sont impliquées dans l'envoi de données réseau ?

La copie mémoire ici, nous nous référons uniquement à la copie mémoire des données à envoyer.

La première opération de copie a lieu après que le noyau a demandé le skb. À ce moment, le contenu des données dans le tampon transmis par l'utilisateur sera copié sur le skb. Si la quantité de données à envoyer est relativement importante, la surcharge de cette opération de copie n'est pas faible.

La deuxième opération de copie a lieu lors de l'entrée dans la couche réseau à partir de la couche transport, et chaque skb sera cloné dans une nouvelle copie. La couche réseau et les pilotes sous-jacents, les interruptions logicielles et d'autres composants supprimeront cette copie une fois la transmission terminée. La couche de transport enregistre le skb d'origine et peut le renvoyer lorsque l'autre côté du réseau n'a pas d'accusé de réception, afin de réaliser la transmission fiable requise dans TCP.

La troisième copie n'est pas nécessaire, uniquement lorsque la couche IP constate que le skb est supérieur au MTU. Il demandera un skb supplémentaire et copiera le skb d'origine dans plusieurs petits skb.

Insérez une digression ici, la copie zéro que tout le monde entend souvent dans l'optimisation des performances réseau, je pense que c'est un peu exagéré. Afin d'assurer la fiabilité de TCP, la deuxième copie ne peut pas du tout être enregistrée. Si le paquet est plus grand que le MTU, la copie pendant la fragmentation est également inévitable.

En voyant cela, je pense que le noyau qui envoie des paquets de données n'est plus une boîte noire que vous ne comprenez pas du tout.

Je suppose que tu aimes

Origine blog.csdn.net/m0_64560763/article/details/131570295
conseillé
Classement