该问题发生于八月份,业务发现部分线上集群出现 10 分钟一次的耗时毛刺。整个系统的架构很简单:
在 Redis Proxy 可以观察到明显的请求耗时毛刺,因此可以确定问题确实出现在 Redis Proxy 调用 Redis 的某个环节
然而,为了定位该问题,仍然花费了很长的时间:
由于无法利用现有指标缩小问题的范围,只能按照可能性从高到底排查:业务请求 > 网络 > 系统 > 应用。
在针对某一个集群的 master failover 到其他节点,请求延迟毛刺消失。对比前后两台机器发现 atop 进程的差异。
$> ps aux|grep atoproot 2442 0.0 0.0 2500 1628 ? S< 2022 42:21 /usr/sbin/atopacctdroot 11530 0.0 0.0 18024 2068 pts/0 S+ 22:08 0:00 grep --color=auto atoproot 182181 1.5 0.0 33784 33184 ? S<Ls 00:00 20:51 /usr/bin/atop -R -w /var/log/atop/atop_20230807 600$> ps aux|grep atoproot 403334 0.0 0.0 16572 2016 pts/0 S+ 22:09 0:00 grep --color=auto atop
停止所有 atop 之后,请求延迟消失
原来,线上部分机器部署的 atop 版本 默认启用了 -R 选项。在 atop 读 /proc/${pid}/smaps 时,会遍历整个进程的页表,期间会持有内存页表的锁。如果在此期间进程发生虚拟内存地址分配,也需要获取锁,就需要等待锁释放。具体到应用层面就是请求耗时毛刺。
除了 atop,cadvisor 等应用也会读取 /proc/${pid}/smaps,虽然默认关闭。由于关闭的方式是通过 disable_metrics 来指定关闭。如果自定义参数时遗漏相关参数,还是会打开该功能触发耗时毛刺
当读取 /proc/${pid}/smaps 获得某个进程虚拟内存区间信息时,究竟发生了什么?
Linux 使用文件将内核里面数据结构通过文件导出到用户空间, smaps 使用到的文件类型就是 seq_file
文件。
// linux/include/linux/seq_file.hstruct seq_file { char *buf; // 指向包含要读取或写入的数据的缓冲区 size_t size; // 缓冲区的大小 size_t from; // 缓冲区中读取或写入的起始位置 size_t count; // 读取或写入的字节数 size_t pad_until; // 将输出填充到某个位置 loff_t index; // 序列中的当前位置 loff_t read_pos; // 当前的读取位置 u64 version; // 文件版本 struct mutex lock; // 锁,确保对 seq_file 操作是线程安全的 const struct seq_operations *op; // 该结构定义了可以对 proc 执行的操作 int poll_event; // 用于 poll 和 select 系统调用 const struct file *file; // 指向文件结构的指针,即 seq_file 关联的 proc void *private; // 私有数据字段,存储特定于文件的数据};struct seq_operations { // 开始读数据项,通常需要加锁,以防止并行访问数据void * (*start) (struct seq_file *m, loff_t *pos);// 停止读数据项,通常需要解锁void (*stop) (struct seq_file *m, void *v); // 找到下一个要处理的数据项void * (*next) (struct seq_file *m, void *v, loff_t *pos); // 打印数据项到临时缓冲区int (*show) (struct seq_file *m, void *v);};
seq_file
使用 file 存储需要关联的进程,seq_operations
定义读取进程数据的操作。使用全局函数 seq_open
把进程与 seq_operations
关联起来
用户态: open(“/proc/pid/smaps”) –> 内核态: proc_pid_smaps_operations.open()
用户态: read(fd) –> 内核态: proc_pid_smaps_operations.read()
具体到 smaps,也是一样的实现 file 相关的方法,在内核中是定义在 proc_pid_smaps_operations 结构:
// linux/fs/proc/base.cREG("smaps", S_IRUGO, proc_pid_smaps_operations)// linux/fs/proc/task_mmu.c// `file_operations` 结构的一个实例,定义 `/proc/PID/smaps` 文件的操作,当操作`/proc/PID/smaps` 文件时被调用const struct file_operations proc_pid_smaps_operations = {.open= pid_smaps_open, // 打开文件的函数.read= seq_read, // 读取文件的函数.llseek= seq_lseek, // 定位文件的函数.release= proc_map_release, // 释放文件的函数};
其中 open() 函数最终会返回一个文件描述符 fd 供后续 read(fd) 函数使用。
// linux/fs/proc/task_mmu.c pid_smaps_open()// --->linux/fs/proc/task_mmu.c do_maps_open()// --->linux/fs/proc/task_mmu.c proc_maps_open()// `seq_operations`结构的实例,定义了一系列的操作函数,在处理`/proc/PID/smaps`文件时被调用static const struct seq_operations proc_pid_smaps_op = {.start= m_start, // 开始操作的函数.next= m_next, // 下一步操作的函数.stop= m_stop, // 停止操作的函数.show= show_smap // 显示操作的函数};static int pid_smaps_open(struct inode *inode, struct file *file){return do_maps_open(inode, file, &proc_pid_smaps_op);}static int do_maps_open(struct inode *inode, struct file *file,const struct seq_operations *ops){return proc_maps_open(inode, file, ops,sizeof(struct proc_maps_private));}static int proc_maps_open(struct inode *inode, struct file *file,const struct seq_operations *ops, int psize){ // 调用`__seq_open_private`函数来打开一个序列文件,并返回一个指向`proc_maps_private`结构的指针。该结构包含了处理`/proc/PID/maps`文件所需的私有数据struct proc_maps_private *priv = __seq_open_private(file, ops, psize);if (!priv)return -ENOMEM; priv->inode = inode; // 将输入参数`inode`赋值给`priv->inode`// 调用`proc_mem_open`函数以读取模式打开`inode`指向的内存对象,并将返回的内存描述符赋值给`priv->mm`priv->mm = proc_mem_open(inode, PTRACE_MODE_READ);if (IS_ERR(priv->mm)) {int err = PTR_ERR(priv->mm);seq_release_private(inode, file);return err;}return 0;}// 打开序列文件并分配私有数据所需的基本操作void *__seq_open_private(struct file *f, const struct seq_operations *ops,int psize){int rc;void *private;struct seq_file *seq;private = kzalloc(psize, GFP_KERNEL);if (private == NULL)goto out;rc = seq_open(f, ops); // 调用`seq_open`函数打开一个序列文件if (rc < 0)goto out_free;、seq = f->private_data; // 获取文件的私有数据,并将其转换为`seq_file`结构的指针seq->private = private;return private;out_free:kfree(private);out:return NULL;}/** *seq_open -initialize sequential file *@file: file we initialize *@op: method table describing the sequence * *seq_open() sets @file, associating it with a sequence described *by @op. @op->start() sets the iterator up and returns the first *element of sequence. @op->stop() shuts it down. @op->next() *returns the next element of sequence. @op->show() prints element *into the buffer. In case of error ->start() and ->next() return *ERR_PTR(error). In the end of sequence they return %NULL. ->show() *returns 0 in case of success and negative number in case of error. *Returning SEQ_SKIP means "discard this element and move on". */int seq_open(struct file *file, const struct seq_operations *op){struct seq_file *p = file->private_data;if (!p) {p = kmalloc(sizeof(*p), GFP_KERNEL);if (!p)return -ENOMEM;file->private_data = p;}memset(p, 0, sizeof(*p));mutex_init(&p->lock); // 初始化`seq_file`结构的锁p->op = op; // 将输入参数`op`赋值给`seq_file`结构的`op`成员 // ... return 0;}struct mm_struct *proc_mem_open(struct inode *inode, unsigned int mode){// 调用`get_proc_task`函数获取`inode`对应的进程的任务结构struct task_struct *task = get_proc_task(inode);struct mm_struct *mm = ERR_PTR(-ESRCH); // ... return mm;}
pid_smaps_open
函数通过参数 inode 找到进程相关的结构并放到 file 的私有数据结构。
当 read
时,调用 seq_read()
函数,它是内核的一个通用架构的函数,特定的 proc 文件(如:smaps)需要提供自己特有的操作方法供通用的 seq_read()
调用。smaps 即是 pid_smaps_open()
函数的 file_operations
参数 &proc_pid_smaps_op
,专门为读取进程虚拟内存区(vma)信息的方法。
/** *seq_read -->read() method for sequential files. *@file: the file to read from *@buf: the buffer to read to *@size: the maximum number of bytes to read *@ppos: the current position in the file * *Ready-made ->f_op->read() */ssize_t seq_read(struct file *file, char __user *buf, size_t size, loff_t *ppos){struct seq_file *m = file->private_data;size_t copied = 0;loff_t pos;size_t n;void *p;int err = 0;mutex_lock(&m->lock); // 锁定`seq_file`结构,以确保线程安全/* * seq_file->op->..m_start/m_stop/m_next may do special actions * or optimisations based on the file->f_version, so we want to * pass the file->f_version to those methods. * * seq_file->version is just copy of f_version, and seq_file * methods can treat it simply as file version. * It is copied in first and copied out after all operations. * It is convenient to have it as part of structure to avoid the * need of passing another argument to all the seq_file methods. */m->version = file->f_version;/* Don't assume *ppos is where we left it */if (unlikely(*ppos != m->read_pos)) {while ((err = traverse(m, *ppos)) == -EAGAIN);if (err) {/* With prejudice... */m->read_pos = 0;m->version = 0;m->index = 0;m->count = 0;goto Done;} else {m->read_pos = *ppos;}}/* grab buffer if we didn't have one */// 如果`seq_file`结构没有缓冲区,需要分配一个if (!m->buf) {m->buf = seq_buf_alloc(m->size = PAGE_SIZE);if (!m->buf)goto Enomem;}/* if not empty - flush it first */// 如果`seq_file`结构的缓冲区不为空,需要先将其内容复制到用户空间if (m->count) {n = min(m->count, size);err = copy_to_user(buf, m->buf + m->from, n);if (err)goto Efault;m->count -= n;m->from += n;size -= n;buf += n;copied += n;if (!m->count)m->index++;if (!size)goto Done;}/* we need at least one record in buffer */pos = m->index;p = m->op->start(m, &pos);// 从序列文件中读取记录,直到出错或缓冲区满while (1) {err = PTR_ERR(p);if (!p || IS_ERR(p))break;err = m->op->show(m, p);if (err < 0)break;if (unlikely(err))m->count = 0;if (unlikely(!m->count)) {p = m->op->next(m, p, &pos);m->index = pos;continue;}if (m->count < m->size)goto Fill;m->op->stop(m, p);kvfree(m->buf);m->count = 0;m->buf = seq_buf_alloc(m->size <<= 1);if (!m->buf)goto Enomem;m->version = 0;pos = m->index;p = m->op->start(m, &pos);}m->op->stop(m, p);m->count = 0;goto Done;Fill:/* they want more? let's try to get some more */// 尝试获取更多的记录,直到出错、缓冲区溢出或缓冲区满while (m->count < size) {size_t offs = m->count;loff_t next = pos;p = m->op->next(m, p, &next);if (!p || IS_ERR(p)) {err = PTR_ERR(p);break;}err = m->op->show(m, p);if (seq_has_overflowed(m) || err) {m->count = offs;if (likely(err <= 0))break;}pos = next;}m->op->stop(m, p);n = min(m->count, size);err = copy_to_user(buf, m->buf, n);if (err)goto Efault;copied += n;m->count -= n;if (m->count)m->from = n;elsepos++;m->index = pos;Done:if (!copied)copied = err;else {*ppos += copied;m->read_pos += copied;}file->f_version = m->version;mutex_unlock(&m->lock); // 解锁`seq_file`结构return copied;Enomem:err = -ENOMEM;goto Done;Efault:err = -EFAULT;goto Done;}
seq_read() 函数的参数:文件对应的内核数据结构 file,用户态 buf 用于存放读取到的信息,size 和ppos 分别是大小和偏移。通用的 seq_read() 函数要将进程的 vma 信息读取给用户的 buf
在开始读取时,m_start
会调用 mmap_read_lock_killable
给整个 mm 结构体加锁;在读取结束时, m_stop
会调用 mmap_read_unlock
解锁。通过 m_next
和 show_smap
每次读取一个 VMA,最终完成所有所有区域的打印。
// linux/fs/proc/task_mmu.cstatic void *m_start(struct seq_file *m, loff_t *ppos){// 获取`seq_file`结构的私有数据,并将其转换为`proc_maps_private`结构的指针struct proc_maps_private *priv = m->private;unsigned long last_addr = *ppos;struct mm_struct *mm;/* See m_next(). Zero at the start or after lseek. */if (last_addr == -1UL)return NULL;// 调用`get_proc_task`函数来获取`inode`对应的进程的任务结构priv->task = get_proc_task(priv->inode);if (!priv->task)return ERR_PTR(-ESRCH);mm = priv->mm;if (!mm || !mmget_not_zero(mm)) {put_task_struct(priv->task);priv->task = NULL;return NULL;}// 尝试获取内存描述符的读锁。如果无法获取,函数释放内存描述符和任务结构并返回错误指针if (mmap_read_lock_killable(mm)) {mmput(mm);put_task_struct(priv->task);priv->task = NULL;return ERR_PTR(-EINTR);}// 初始化虚拟内存区域的迭代器vma_iter_init(&priv->iter, mm, last_addr);hold_task_mempolicy(priv); // 获取任务的内存策略if (last_addr == -2UL)return get_gate_vma(mm);// 获取虚拟内存区域return proc_get_vma(priv, ppos);}static void *m_next(struct seq_file *m, void *v, loff_t *ppos){if (*ppos == -2UL) {*ppos = -1UL;return NULL;}return proc_get_vma(m->private, ppos);}static void m_stop(struct seq_file *m, void *v){struct proc_maps_private *priv = m->private;struct mm_struct *mm = priv->mm;if (!priv->task)return;release_task_mempolicy(priv); // 释放任务的内存策略mmap_read_unlock(mm); // 解锁内存描述符的读锁mmput(mm); // 减少内存描述符的引用计数,如果引用计数为零,释放内存描述符put_task_struct(priv->task); // 减少任务结构的引用计数,如果引用计数为零,释放任务结构priv->task = NULL;}static int show_smap(struct seq_file *m, void *v){struct vm_area_struct *vma = v;struct mem_size_stats mss;memset(&mss, 0, sizeof(mss));smap_gather_stats(vma, &mss, 0);show_map_vma(m, vma);SEQ_PUT_DEC("Size: ", vma->vm_end - vma->vm_start);SEQ_PUT_DEC(" kB\nKernelPageSize: ", vma_kernel_pagesize(vma));SEQ_PUT_DEC(" kB\nMMUPageSize: ", vma_mmu_pagesize(vma));seq_puts(m, " kB\n");__show_smap(m, &mss, false);seq_printf(m, "THPeligible: %8u\n", hugepage_vma_check(vma, vma->vm_flags, true, false, true));if (arch_pkeys_enabled())seq_printf(m, "ProtectionKey: %8u\n", vma_pkey(vma));show_smap_vma_flags(m, vma);return 0;}/* Show the contents common for smaps and smaps_rollup */static void __show_smap(struct seq_file *m, const struct mem_size_stats *mss,bool rollup_mode){SEQ_PUT_DEC("Rss: ", mss->resident);SEQ_PUT_DEC(" kB\nPss: ", mss->pss >> PSS_SHIFT);SEQ_PUT_DEC(" kB\nPss_Dirty: ", mss->pss_dirty >> PSS_SHIFT);if (rollup_mode) {/* * These are meaningful only for smaps_rollup, otherwise two of * them are zero, and the other one is the same as Pss. */SEQ_PUT_DEC(" kB\nPss_Anon: ",mss->pss_anon >> PSS_SHIFT);SEQ_PUT_DEC(" kB\nPss_File: ",mss->pss_file >> PSS_SHIFT);SEQ_PUT_DEC(" kB\nPss_Shmem: ",mss->pss_shmem >> PSS_SHIFT);}SEQ_PUT_DEC(" kB\nShared_Clean: ", mss->shared_clean);SEQ_PUT_DEC(" kB\nShared_Dirty: ", mss->shared_dirty);SEQ_PUT_DEC(" kB\nPrivate_Clean: ", mss->private_clean);SEQ_PUT_DEC(" kB\nPrivate_Dirty: ", mss->private_dirty);SEQ_PUT_DEC(" kB\nReferenced: ", mss->referenced);SEQ_PUT_DEC(" kB\nAnonymous: ", mss->anonymous);SEQ_PUT_DEC(" kB\nKSM: ", mss->ksm);SEQ_PUT_DEC(" kB\nLazyFree: ", mss->lazyfree);SEQ_PUT_DEC(" kB\nAnonHugePages: ", mss->anonymous_thp);SEQ_PUT_DEC(" kB\nShmemPmdMapped: ", mss->shmem_thp);SEQ_PUT_DEC(" kB\nFilePmdMapped: ", mss->file_thp);SEQ_PUT_DEC(" kB\nShared_Hugetlb: ", mss->shared_hugetlb);seq_put_decimal_ull_width(m, " kB\nPrivate_Hugetlb: ", mss->private_hugetlb >> 10, 7);SEQ_PUT_DEC(" kB\nSwap: ", mss->swap);SEQ_PUT_DEC(" kB\nSwapPss: ",mss->swap_pss >> PSS_SHIFT);SEQ_PUT_DEC(" kB\nLocked: ",mss->pss_locked >> PSS_SHIFT);seq_puts(m, " kB\n");}static struct vm_area_struct *proc_get_vma(struct proc_maps_private *priv,loff_t *ppos){struct vm_area_struct *vma = vma_next(&priv->iter);if (vma) {*ppos = vma->vm_start;} else {*ppos = -2UL;vma = get_gate_vma(priv->mm);}return vma;}// linux/include/linux/mmap_lock.hstatic inline int mmap_read_lock_killable(struct mm_struct *mm){int ret;__mmap_lock_trace_start_locking(mm, false);ret = down_read_killable(&mm->mmap_lock);__mmap_lock_trace_acquire_returned(mm, false, ret == 0);return ret;}static inline void mmap_read_unlock(struct mm_struct *mm){__mmap_lock_trace_released(mm, false);up_read(&mm->mmap_lock);}
smaps 读取的重点在于:
有时只是想获取一下进程的 PSS 占用,是不是可以省去遍历 VMA 的部分呢? google 的优化是增加 /proc/pid/smaps_rollup,据 Patch 描述性能改善了 12 倍,节省几百毫秒。
By using smaps_rollup instead of smaps, a caller can avoid the
significant overhead of formatting, reading, and parsing each of a
large process’s potentially very numerous memory mappings. For
sampling system_server’s PSS in Android, we measured a 12x speedup,
representing a savings of several hundred milliseconds.
smaps_rollup
的具体实现如下,可以看到持锁的粒度和时长都大大降低,当有写入请求等待锁时,还会临时释放锁。
static int show_smaps_rollup(struct seq_file *m, void *v){// 获取`seq_file`结构的私有数据,并将其转换为`proc_maps_private`结构的指针struct proc_maps_private *priv = m->private;struct mem_size_stats mss = {};struct mm_struct *mm = priv->mm;struct vm_area_struct *vma;unsigned long vma_start = 0, last_vma_end = 0;int ret = 0;VMA_ITERATOR(vmi, mm, 0);// 调用`get_proc_task`函数来获取`inode`对应的进程的任务结构priv->task = get_proc_task(priv->inode);if (!priv->task)return -ESRCH;if (!mm || !mmget_not_zero(mm)) {ret = -ESRCH;goto out_put_task;}// 尝试获取内存描述符的读锁。如果无法获取,函数返回错误码ret = mmap_read_lock_killable(mm);if (ret)goto out_put_mm;hold_task_mempolicy(priv); // 获取任务的内存策略vma = vma_next(&vmi); // 获取下一个虚拟内存区域if (unlikely(!vma))goto empty_set;vma_start = vma->vm_start;// 遍历所有的虚拟内存区域,并收集统计信息do {// 调用`smap_gather_stats`函数来收集当前VMA的统计信息smap_gather_stats(vma, &mss, 0);last_vma_end = vma->vm_end;/* * Release mmap_lock temporarily if someone wants to * access it for write request. */ // 如果内存映射的锁存在争用,需要暂时释放锁以允许其他线程进行写操作if (mmap_lock_is_contended(mm)) {vma_iter_invalidate(&vmi);mmap_read_unlock(mm);ret = mmap_read_lock_killable(mm);if (ret) {release_task_mempolicy(priv);goto out_put_mm;}/* * After dropping the lock, there are four cases to * consider. See the following example for explanation. * * +------+------+-----------+ * | VMA1 | VMA2 | VMA3 | * +------+------+-----------+ * | | | | * 4k 8k 16k 400k * * Suppose we drop the lock after reading VMA2 due to * contention, then we get: * *last_vma_end = 16k * * 1) VMA2 is freed, but VMA3 exists: * * vma_next(vmi) will return VMA3. * In this case, just continue from VMA3. * * 2) VMA2 still exists: * * vma_next(vmi) will return VMA3. * In this case, just continue from VMA3. * * 3) No more VMAs can be found: * * vma_next(vmi) will return NULL. * No more things to do, just break. * * 4) (last_vma_end - 1) is the middle of a vma (VMA'): * * vma_next(vmi) will return VMA' whose range * contains last_vma_end. * Iterate VMA' from last_vma_end. */vma = vma_next(&vmi); // 获取下一个VMA/* Case 3 above */if (!vma) // 如果没有更多的VMA,跳出循环break;/* Case 1 and 2 above */if (vma->vm_start >= last_vma_end) // 如果下一个 VMA 的开始地址大于或等于上一个 VMA 的结束地址,跳过当前迭代continue;/* Case 4 above */if (vma->vm_end > last_vma_end) // 如果下一个 VMA 的结束地址大于上一个 VMA 的结束地址,从上一个 VMA 的结束地址开始收集下一个 VMA 的统计信息smap_gather_stats(vma, &mss, last_vma_end);}} for_each_vma(vmi, vma);empty_set:// 显示虚拟内存区域的头部前缀show_vma_header_prefix(m, vma_start, last_vma_end, 0, 0, 0, 0);seq_pad(m, ' ');seq_puts(m, "[rollup]\n");// 显示内存映射的统计信息__show_smap(m, &mss, true);release_task_mempolicy(priv); // 释放任务的内存策略mmap_read_unlock(mm); // 解锁内存描述符的读锁out_put_mm:// 减少内存描述符的引用计数,如果引用计数为零,释放内存描述符mmput(mm); out_put_task:// 减少任务结构的引用计数,如果引用计数为零,释放任务结构put_task_struct(priv->task);priv->task = NULL;return ret;}
正如前面提到,整个故障定位过程耗时较长,定位方式也不具备普适性。针对延迟毛刺性问题,是否有什么普适的定位方法呢?
首先,定位非必现的问题,首要条件就是获取问题发生的现场快照,获取更多的问题细节。针对非必现的问题最好的方式,就是在可能出现问题的现场部署合适的脚本获取现场快照。
其次,最重要的是定位工具。本问题之所以定位耗时较长,是因为没有使用合适的工具缩小故障的范围。就进程的调用耗时而言,由两部分耗时组成:用户空间和内核空间。
由于在线的 Redis 版本缺少 P99 指标,可以使用 funcslower(bcc) 可以定位或排除 Redis 执行毛刺,将范围缩小到网络或者单机问题。
$> funcslower -UK -u 5000 -p 324568 '/var/lib/docker/overlay2/69e6c3d262a1aed8db1a8b16ddfc34c7c78999f527e028857dc2e5248ae5704a/merged/usr/local/bin/redis-server:processCommand'
使用系统调用性能测试工具,通过查看系统调用的长尾延迟,可以确定系统层面是否存在问题。满足要求的工具可能有:
syscount(bcc)
syscount 并不能直接查看 outliner,但可以通过对比不同时间区间的延迟变化发现问题。使用它在问题现场,抓取到延迟前后 mmap
系统调用前后变化,问题出现前耗时为 11 us,问题发生时耗时为 177 ms,如下所示:
# ebpf 抓取故障前后 mmap 耗时$> syscount -L -i 30 -p $PID[21:39:27]SYSCALL COUNT TIME (us)epoll_pwait 24952 4322184.374write 34458 331600.262read 26400 59001.053open 50 527.602epoll_ctl 70 93.506getpid 50 39.793close 50 35.262munmap 1 26.372getpeername 12 15.252mmap 1 11.003[21:40:14]SYSCALL COUNT TIME (us)epoll_pwait 24371 4189948.513write 34110 296551.821mmap 1 177477.938read 25878 57099.880open 48 504.271epoll_ctl 68 104.834getpid 49 45.939close 49 37.919getpeername 8 13.127accept 2 7.896
perf trace
另外一个更好用的工具是 perf trace,相较于 syscount 提供了 histogram 图,可以直观的发现长尾问题,使用示例如下所示(非问题现场):
# perf trace 示例$> perf trace -p $PID -s syscall calls total min avg max stddev (msec) (msec) (msec) (msec) (%) --------------- -------- --------- --------- --------- --------- ------ epoll_pwait 53841 14561.545 0.000 0.270 4.538 0.53% write 56177 757.799 0.005 0.013 0.047 0.09% read 55591 219.250 0.001 0.004 0.702 0.67% open 170 2.468 0.012 0.015 0.043 1.69% getpid 171 1.668 0.002 0.010 1.069 63.91% mmap 76 0.795 0.007 0.010 0.018 2.14% munmap 77 0.643 0.003 0.008 0.030 7.91% epoll_ctl 151 0.533 0.001 0.004 0.014 4.26% close 173 0.291 0.001 0.002 0.012 3.87% getpeername 24 0.064 0.002 0.003 0.004 4.76% accept 8 0.045 0.003 0.006 0.011 18.34% setsockopt 20 0.040 0.001 0.002 0.003 5.50% fcntl 16 0.029 0.001 0.002 0.006 15.83% getrusage 3 0.008 0.001 0.003 0.006 48.77% getcwd 1 0.006 0.006 0.006 0.006 0.00%
定位到 mmap 耗时异常之后,其实相关工作就可以交给内核同事处理了,毕竟术业有专攻。要想查看慢在哪里,可以通过 func_graph
工具定位到耗时异常的函数
# tracer: function_graph## CPU DURATION FUNCTION CALLS# | | | | | | | 0) | sys_open() { 0) | do_sys_open() { 0) | getname() { 0) | kmem_cache_alloc() { 0) 1.382 us | __might_sleep(); 0) 2.478 us | } 0) | strncpy_from_user() { 0) | might_fault() { 0) 1.389 us | __might_sleep(); 0) 2.553 us | } 0) 3.807 us | } 0) 7.876 us | } 0) | alloc_fd() { 0) 0.668 us | _spin_lock(); 0) 0.570 us | expand_files(); 0) 0.586 us | _spin_unlock();
针对于 mmap_lock 的锁占用,要想排查持有该锁的进程列表。在内核高版本中封装了 mmap_lock 相关函数,并在其中增加了 tracepoint,可以使用 bpftrace 等工具统计持有写锁的进程、调用栈等
$> perf list |grep mmap mmap:vm_unmapped_area [Tracepoint event] mmap_lock:mmap_lock_acquire_returned [Tracepoint event] mmap_lock:mmap_lock_released [Tracepoint event] mmap_lock:mmap_lock_start_locking [Tracepoint event] syscalls:sys_enter_mmap [Tracepoint event] syscalls:sys_exit_mmap [Tracepoint event]$> bpftrace -e 'tracepoint:mmap_lock:mmap_lock_start_locking /args->write == true/{ @[comm, kstack] = count();}'
相关 perf 命令来自 字节跳动SYSTech 分享,遗憾的是由于发生问题的内核版本较旧,并未实操相关该定位过程。
当然,从 持锁这个更宽泛的观测纬度来看,可以找出有相关动作的进程,如下所示:
$> trace 'rwsem_down_read_slowpath(struct rw_semaphore *sem, int state) "count=0x%lx owner=%s", sem->count.counter, ((struct task_struct *)((sem->owner.counter)&~0x7))->comm'/virtual/main.c:44:66: warning: comparison of array '((struct task_struct *)((sem->owner.counter) & ~7))->comm' not equal to a null pointer is always true [-Wtautological-pointer-compare] if (((struct task_struct *)((sem->owner.counter)&~0x7))->comm != 0) { ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~ ~1 warning generated.PID TID COMM FUNC -195453 195458 monitor rwsem_down_read_slowpath count=0x100 owner=195453 195458 monitor rwsem_down_read_slowpath count=0x101 owner=ip195453 195756 monitor rwsem_down_read_slowpath count=0x101 owner=sh195453 195458 monitor rwsem_down_read_slowpath count=0x101 owner=python195453 195458 monitor rwsem_down_read_slowpath count=0x101 owner=python195453 195458 monitor rwsem_down_read_slowpath count=0x101 owner=python212360 212360 runc rwsem_down_read_slowpath count=0x100 owner=212360 212360 runc rwsem_down_read_slowpath count=0x101 owner=runc...
然而,加锁解锁耗时跟持锁耗时是两个完全不同的概念,因此并不能直接定位到持锁耗时较长的进程,所以仍需额外的工作进一步排查。
下次遇到同步调用场景下的延迟毛刺,就可以选择合适的工具根据函数执行耗时快速定位。然而采用 streaming 模式的异步请求/响应的延迟问题,仍然需要再深入学习探索。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/12-22-2023/redis-latency-spike.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
在一些对响应延迟极度敏感的场景下,服务端负载不均会显著增加 P99/P999 延迟,例如:Redis 服务接入。假如后端服务能力一致,使用 DNS 作为服务发现的情况下,怎样才能让负载均衡到不同的服务器(注意:不仅仅是负载分配,而是负载均衡)。通常意义上,我们倾向于认为 DNS 解析返回的结果是 Round-robin 的,然而实际上并非如此。
所有 DNS 服务器都属于以下四个类别之一:
在典型 DNS 查找中,四种 DNS 服务器协同工作来完成客服端发起的域名到 IP 地址的解析任务。
客户端不会直接与 DNS 域名服务器通信,递归解析器(也称为 DNS 解析器)作为客户端与 DNS 域名服务器的中间人,是 DNS 查询中的第一站。从客户端收到 DNS 查询后,递归解析器将使用缓存的数据进行响应,或向 Root 域名服务器发送请求,接着向 TLD 域名服务器发送另一个请求,然后向权威性域名服务器发送最后一个请求。收到来自权威性域名服务器的响应后,递归解析器将向客户端发送响应。
为了满足访问加速、私有(内部)域名、防止 DNS 劫持、智能路由等需求,实际生产环境中会有多级的递归解析器。递归解析器会缓存上游 DNS 服务的查询记录,并根据配置转发未命中缓存的 DNS 查询请求给上游 DNS 服务。
以公有云 VPC 为例,可以在主机部署 node-local-dns,在 Kubernetes 集群部署 CoreDNS,在 VPC 内使用 AWS Route 53
等 DNS 服务。整体效果,如下图:
当 Kubernetes 集群内的容器进行 DNS 解析时,请求首先被转发给主机的 DNS 服务,在未命中缓存是逐级转发给上游的递归解析器。最后一级递归解析器,通过迭代查询返回解析结果。
使用 CoreDNS 搭建域名服务,配置如下:
# Corefile.:53 { log errors forward . 192.168.65.7 # 未命中私有域名、缓存的请求转发给主机 DNS file /etc/coredns/db/example.com example.com # 私有域名 cache 30 # 缓存 30 秒 loop reload loadbalance round_robin # 充当循环DNS负载均衡器,随机响应中 A、AAAA 和 MX 记录的顺序。}# /etc/coredns/db/example.com# www.example.com 两条 A 记录, 两 IP 均为 mock IP# www.cname.example.com CNAME 到 www.example.com...www IN A 192.168.8.7 www IN A 192.168.8.8www.cname IN CNAME www...
CoreDNS 通过 forward 插件实现递归查询;loadbalance 插件实现轮询 DNS;cache 插件根据域名和记录进行缓存。
使用 dig 验证私有域名 www.example.com
、www.cname.example.com
和 serverfault.com
,可以看到正常解析,响应中 IP 顺序随机:
$> dig -p 53 @127.0.0.1 +noall +answer www.example.com www.example.com.21INA192.168.8.8www.example.com.21INA192.168.8.7$> dig -p 53 @192.168.3.2 +noall +answer www.cname.example.comwww.cname.example.com.18INCNAMEwww.example.com.www.example.com.18INA192.168.8.7www.example.com.18INA192.168.8.8$> dig -p 53 @127.0.0.1 +noall +answer serverfault.comserverfault.com.30INA104.18.23.101serverfault.com.30INA104.18.22.101
统计 serverfault.com
返回记录的首位结果:
$> for i in $(seq 1 10); do dig +short serverfault.com | head -n 1; done | sort | uniq -c 4 104.18.22.101 6 104.18.23.101
即使排除缓存失效再缓存的干扰,CoreDNS 结果也并不总是 5:5,看起来与想象的 round-robin
不同。
深入 CoreDNS loadbalance 插件的源代码,可以看到:
round-robin
shuffle round-robin
shufflefunc roundRobin(in []dns.RR) []dns.RR {cname := []dns.RR{}address := []dns.RR{}mx := []dns.RR{}rest := []dns.RR{}for _, r := range in {switch r.Header().Rrtype {case dns.TypeCNAME:cname = append(cname, r)case dns.TypeA, dns.TypeAAAA: // IPv4, IPv6address = append(address, r)case dns.TypeMX:mx = append(mx, r)default:rest = append(rest, r)}}roundRobinShuffle(address)roundRobinShuffle(mx)out := append(cname, rest...)out = append(out, address...)out = append(out, mx...)return out}
再看 roundRobinShuffle
的实现,可以看到排序规则:根据随机的消息 ID 做 random_shuffle(随机排列组合),而非像击球队伍中的运动员一样:每个人都轮到一次,然后移到队伍的后面。
func roundRobinShuffle(records []dns.RR) {switch l := len(records); l {case 0, 1:breakcase 2:if dns.Id()%2 == 0 {records[0], records[1] = records[1], records[0]}default:for j := 0; j < l; j++ {p := j + (int(dns.Id()) % (l - j))if j == p {continue}records[j], records[p] = records[p], records[j]}}}// Id by default returns a 16-bit random number to be used as a message id. The// number is drawn from a cryptographically secure random number generator.// This being a variable the function can be reassigned to a custom function.// For instance, to make it return a static value for testing:////dns.Id = func() uint16 { return 3 }var Id = id// id returns a 16 bits random number to be used as a// message id. The random provided should be good enough.func id() uint16 {var output uint16err := binary.Read(rand.Reader, binary.BigEndian, &output)if err != nil {panic("dns: reading random id failed: " + err.Error())}return output}
从 Wiki 的解释可以看出来,此 Round-robin 是指排列组合,更类似于 Random:
The order in which IP addresses from the list are returned is the basis for the term round robin. With each DNS response, the IP address sequence in the list is permuted. – Round-robin DNS
cache [TTL] [ZONES...]
缓存中的每个元素都根据其 TTL 进行缓存(TTL为最大值)。缓存有 256 个 Shard,默认情况下每 Shard 最多保存 39 条数据,总大小为 256*39=9984 条数据。
如果一个域名有多条 A 记录,当发送 DNS 请求时:
由于 RFC 缺少相关的规定,在传输协议的范围内,不同的名称服务器有不同的路由策略。两者共同决定了返回的记录和顺序
大多数 DNS RFC1034 请求通过 UDP RFC 768 进行。IPv4规定主机必须能够重组 少于等于 576 字节的数据包,包含 IPv4 报头和 8 字节 UDP报头。
因此基于 UDP 的 DNS ,有效载荷限制为小于 512 字节,保证了如果 DNS 数据包在传输中被分段,可以重新组装,降低数据包被随机丢弃的可能性。超过 512 字节的响应将被截断,解析器必须通过 TCP 重新发出请求。
如果解析器支持 EDNS0,也可以通过 UDP 响应最多 4096 字节,且不会被截断。
常见的一种路由策略设置是:轮询 DNS
当查询有多条记录时,名称服务器执行循环 DNS。在一个请求和下一个请求时,发送响应的顺序会有所不同。大多数客户端将连接到第条记录,因此可以实现负载平衡。
分别使用 8.8.8.8
和 CoreDNS 分别作为名称服务器。前者直接解析返回,后者配置 loadbalance round_robin
shuffle 返回。
loadbalance [round_robin | weighted WEIGHTFILE] { reload DURATION }
查看 serverfault.com
返回记录的顺序,可以看到响应首位的结果差异
$> dig +short serverfault.com104.18.23.101104.18.22.101# 8.8.8.8$> for i in $(seq 1 10); do dig +short serverfault.com | head -n 1; done | sort | uniq -c 10 104.18.23.101# CoreDNS: round-robin $> for i in $(seq 1 10); do dig +short serverfault.com | head -n 1; done | sort | uniq -c 4 104.18.22.101 6 104.18.23.101
除了 CoreDNS 的 round-robin
,AWS route 53 之类的 DNS 服务提供了更多路由策略,常见:
值得注意的是,由于 CoreDNS 等下游递归解析器,在启用缓存时,并不感知上游的路由策略,因此会导致上游策略失效,甚至导致缺陷。
假设,上游域名服务随机返回部分 IP,该部分 IP 会持续缓存直至缓存失效。在缓存失效前所有请求都会集中到该部分 IP,导致较为严重的访问倾斜。
在 Linux 上并不存在一个 syscall
用于域名解析,实际上大多数程序是通过一个 C 标准库调用 getaddrinfo 完成的。
dig
、nslookup
等,是查询 DNS 域名服务的工具,因此没有调用resolver
库
通过 strace 命令可以看到执行的部分细节:
$> strace -e trace=openat -f ping -c1 serverfault.comopenat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libcap.so.2", O_RDONLY|O_CLOEXEC) = 3openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libidn2.so.0", O_RDONLY|O_CLOEXEC) = 3openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libunistring.so.2", O_RDONLY|O_CLOEXEC) = 3openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 5openat(AT_FDCWD, "/etc/host.conf", O_RDONLY|O_CLOEXEC) = 5openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 5openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 5openat(AT_FDCWD, "/etc/gai.conf", O_RDONLY|O_CLOEXEC) = 5PING serverfault.com (104.18.22.101) 56(84) bytes of data.openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 564 bytes from 104.18.22.101 (104.18.22.101): icmp_seq=1 ttl=62 time=68.6 ms
可以看到依次读取了 /etc/nsswitch.conf
,/etc/host.conf
,/etc/resolv.conf
/etc/gai.conf
四个配置文件, DNS 解析的策略也跟他们相关。通过 POSIX 文档,可以了解四个配置文件的作用
Name Service Switch (NSS) 配置文件,管理了各种信息来源的类别和顺序。每一行可以当做是一个数据库,冒号前面的是信息类型,冒号后面是数据来源或服务。 举例:
...hosts: files dnsnetworks: files...
域名解析时,gethostbyname
会读取 hosts 一行,并从 files 和 dns 两个来源依次获取数据:
/lib/libnss_files.so.X
:实现了 “files” 数据源,读取本地文件:/etc/hosts
/lib/libnss_dns.so.X
:实现 “dns” 数据源,访问远端 DNS 服务。相比于固定搜索顺序的硬编码, NSS 提供了一种更灵活的方法可以动态更新搜索顺序,插件化的增减来源。
host.conf
包含了为解析库声明的配置信息. 每行含一个配置关键字,其后跟着合适的配置信息.。举例:
# The "order" line is only used by old versions of the C library.order hosts,bindmulti on
/etc/hosts
文件,再使用 name server 解析。bind(Berkeley Internet Name Domain),一种开源 DNS 协议实现。(仅 glibc 2.4及更早版本生效,更新版本见 NSSresolv.conf
是解析器的核心配置,举例:
$> cat /etc/resolv.confoptions rotate options timeout:2 options attempts:3 options single-request-reopennameserver 8.8.4.4nameserver 8.8.8.8
其配置项既要满足解析的基本要求:
search
、ndots:n
nameserver
、rotate
配置
rotate
时
- 以 Round Robin 的形式挑选nameserver
,而非每次都选择第一个,起到负载均衡的的作用。一次性请求的工具不生效,因为只有一次请求。不配置
rotate
时
- 首先使用第一个 nameserver
- 如果请求成功,永远不会继续尝试后续的 nameserver
- 如果请求失败且尚未超时,则继续使用后续 nameserver,直至成功
timeout
、attempts
sortlist
也要兼容历史变迁的沧桑:
use-vc
single-request-reopen
、single-request
调用 getaddrinfo
可能会返回多个结果。根据 rfc3484 / rfc6724 的要求,需要根据根据来源 IP 与结果 IP 进行最长匹配排序,以便相同子网里的 IP 在列表中排在首位,以得到成功率最高的结果。当然相关排序机制也可以通过 /etc/gai.conf
配置控制。
示例:
换句话说,按照最新规范,DNS 解析返回的结果应当是固定顺序的,而非 round-robin,那么当 DNS server 返回 round-robin 的结果时,就会因为解析器的排序而不生效,导致新旧版本 library 之间行为不一。
最新的规范的前提都是 IPv6,然而 IPv6 到目前位置支持的并不理想,并且考虑基于兼容性的考虑:当返回结果中仅有 IPv4 时,不适用最长匹配相关的规则,也就不会调整结果的相对顺序(稳定排序)。
func Dial(network, address string) (Conn, error)
Golang 创建连接时,使用 Dial 连接到 named network
的地址。
已知 network
类型有:
Golang 默认使用双栈(IPv4&IPv6)DNS 解析,当 IPV6 不能访问时,支持 IPv6 的程序需要延迟几秒钟才能正常切换到 IPv4,为了不影响用户体验可以指定 network
为 tcp4
,直接禁用 IPv6。
综述,一次 DNS 解析,如果指定 network 为 TCP,在启用 IPv6 时:
DNS 本身作为服务发现,通过轮询 DNS 提供了最基本的负载分配功能,而不能保证完美的负载均衡。对负载有极致需求的业务,建议自行负载均衡,策略参考:
- 动态(定时)更新 DNS 对应的 IP 列表
- 根据负载均衡策略从 IP 列表中选择合适的 IP
- 根据 IP 从连接池中获取连接,发起请求
备注:由于 Linux 发行版本众多,也有多种 Resolver 库、DNS 递归解析器,再叠加复杂的版本历史。因此本文中的众多细节仅供参考,实际情况建议使用 strace、tcpdump、ebpf tools 等工具确认
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/10-08-2023/dive-into-dns-resolution.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
轮询 DNS一直以来都是实现粗略且廉价的负载均衡和将访问者分散到多个主机上的方法,当他们尝试使用具有静态内容的单个主机/服务时。通过在 DNS 区域中设置一条 A 记录来解析为多个 IP 地址,客户端将以半随机的方式获得不同的结果,从而在不同时间访问不同服务器:
server IN A 192.168.0.1server IN A 10.0.0.1server IN A 127.0.0.1
例如,如果是一个小型开源项目,那么它是一种完美的方式来提供分布式服务,该服务以单一名称出现,但由互联网上的多个分布式独立服务器托管。它也被高端网络服务器使用,例如 www.google.com 和 www.yahoo.com 。
如果您是一名老派黑客,如果您从 Stevens 的原著中学习了套接字和 TCP/IP 编程,如果您是在 BSD unix 环境长大,您就会知道可以使用 gethostbyname()等方法来解析主机名。这是一个 POSIX 和单一 UNIX 规范,基本上一直存在。当对给定的循环主机名调用 gethostbyname() 时,该函数返回一个地址数组。该地址列表将以看似随机的顺序排列。如果应用程序只是按照接收到的顺序遍历列表并连接它们,则轮询概念非常有效。
gethostbyname() 只适用 IPv4,涉及 IPv6 就崩溃了。它必须被更好的东西取代。getaddrinfo () 加入,也是 POSIX(在 RFC 3943定义,并在 RFC 5014再次更新)。支持 IPv6 和更多功能的现代函数。这是世界所需要的闪亮之物!
因此,(世界好的部分)将所有调用 gethostbyname() 替换为调用 getaddrinfo() ,现在一切都支持 IPv6,一切都很好?不完全如此。因为其中涉及微妙之处。比如函数返回地址的顺序。2003 年,IETF 人员发布了 RFC 3484,详细说明了 _Internet 协议版本 6 的默认地址选择_,并以此为指导,大多数(全部?)实现现在已改为按该顺序返回地址列表。然后它将成为按“首选”顺序排列的主机列表。突然间,应用程序将按照“从 IPv6 升级路径的角度来看很聪明的顺序”,同时遍历 IPv4 和 IPv6 地址,。
因此,相比旧的轮询 DNS 的方法:多个地址(无论是 IPv4 或 IPv6 或两者)。随着如何返回地址的新想法,这种负载平衡方式不再有效。现在 getaddrinfo() 每次调用基本上都返回相同的顺序。我在 2005 年注意到这一点,并在 glibc 黑客邮件列表上发布了一个问题:http://www.cygwin.com/ml/libc-alpha/2005-11/msg00028.html正如您所看到的,我的问题被愉快地忽略了,并没有人回应过。顺序似乎主要由上述 RFC 和本地 /etc/gai.conf 文件决定,但如果您的目标是获得良好的轮询,两者都无济于事。其他人也注意到了这个缺陷 有些人激烈争辩说这是一件坏事,当然也有相反的人声称这是正确的行为,并且无论如何,像这样做轮询 DNS 一开始就是一个坏主意。对大量常见实用程序的影响很简单,当它们启用 IPv6 时,也会同时禁用循环 DNS。
由于 getaddrinfo() 现在已经这样工作了近十年,我们可以忘掉“修复”它。。由于 gai.conf 需要本地编辑来提供不同的函数响应,因此它不是答案。但也许更糟糕的是,由于 getaddrinfo() 现在以某种优先顺序返回地址,,因此很难在顶部“粘贴”一个简单洗牌返回结果的层。洗牌需要考虑 IP 版本等因素。而且它将变得特定于应用程序,因此必须一次作用于一个程序。流行的浏览器似乎不太受到 getaddrinfo 的影响。。我的猜测是,因为他们致力于进行异步名称解析,以便名称解析不会阻塞进程,它们采取了不同的方法,因此拥有自己的代码。在 curl 情况下,即使支持IPv6,它也可以使用 c-ares 作为解析器后端构建,并且 c-ares 不提供 getaddrinfo的排序功能,因此在这些情况下,curl 将更像使用 gethostbyname 时那样与轮询 DNS 一起工作。
我所知道的所有替代方案的缺点是它们并没有充分利用朴素 DNS。为了避免我提到的问题,您可以调整 DNS 服务器以对不同的用户做出不同的响应。这样,您既可以随机以轮询的方式响应不同的地址,也可以尝试通过 PowerDNS 的 geobackend 功能等使其变得更加智能。当然,我们都知道 A) geoip粗糙且经常错误,B) 现实世界地理位置与网络拓扑并不匹配。
在此期间,另一个与连接相关的问题出现了。事实上,IPv6 连接通常作为双栈计算机的第二个选项,而且事实上 IPv6 如今主要出现在双栈中。这可悲地惩罚了 IPv6 的早期采用者(是的,不幸的是,IPv6 仍然必须被视为早期),因为这些服务将比旧的纯 IPv4 服务慢。
对于克服这个问题的方法似乎有一个普遍的共识:happy eyeballs 方法。简而言之,它建议同时尝试两个(或所有)选项,响应最快的获胜并被使用。这就需要同时解析 A 和 AAAA 名称,如果两者都得到响应,就连接到 IPv4 和 IPv6 地址,看看哪一个连接速度最快。
这当然不仅仅是替换一两个函数的问题。要实施这种方法,您需要做一些全新的事情。例如,仅执行 getaddrinfo() + 循环地址并尝试 connect() 根本不起作用。您基本上要么启动两个线程,并在一个线程中执行 IPv4-only 路由,并在另一个线程中执行 IPv6 路由,_或者 _您必须发出非阻塞解析器调用以在同一线程中并行执行 A 和 AAAA 解析,并且当第一个响应到达时,您会触发非阻塞 connect() …
我的观点是,无论如何,在您良好的旧套接字应用程序中引入 Happy Eyeballs 都需要进行一些相当大的改造。这样做很可能还会影响您的应用程序处理轮询 DNS 的方式,因此现在您有机会重新考虑您的选择和代码!
原文: getaddrinfo with round robin DNS and happy eyeballs
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/09-07-2023/getaddrinfo-with-round-robin-dns-and-happy-eyeballs-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
使用 go-redis 作为 client 访问 redis cluster。ReadTimeout 配置为 1 ms,但请求整体耗时 76 ms,并且成功返回(没有超时)。
为什么 ReadTimeout 没有生效?
弄清楚这个问题,最简单的做法是查看源码。go-redis 命令处理的逻辑在 func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool, error)
方法。
func (c *baseClient) _process(ctx context.Context, cmd Cmder, attempt int) (bool, error) {// ...if err := c.withConn(ctx, func(ctx context.Context, cn *pool.Conn) error { // 写入请求if err := cn.WithWriter(c.context(ctx), c.opt.WriteTimeout, func(wr *proto.Writer) error {return writeCmd(wr, cmd)}); err != nil {atomic.StoreUint32(&retryTimeout, 1)return err} // 读取响应if err := cn.WithReader(c.context(ctx), c.cmdTimeout(cmd), cmd.readReply); err != nil {if cmd.readTimeout() == nil {atomic.StoreUint32(&retryTimeout, 1)} else {atomic.StoreUint32(&retryTimeout, 0)}return err}return nil}); err != nil {retry := shouldRetry(err, atomic.LoadUint32(&retryTimeout) == 1)return retry, err}return false, nil}
该方法负责根据配置控制重试次数:
c.withConn
:从连接池获取链接。cn.WithWriter
:发送请求到连接。即,实际使用 opt.WriteTimeout
的地方cn.WithReader
:接收连接上的响应。即,实际使用 opt.ReadTimeout
的地方由于 Redis 存在阻塞式命令,因此首先调用
c.cmdTimeout
判断是否存在命令维度的读超时时间(优先级:命令维度 > Client 维度)。
打开 WithReader 可以看到 cn.deadline
计算读取的截止时间并设置给 conn
(注意:截止时间为绝对时间,因此连接复用时,需要在每次调用前更新截止时间) 。
func (cn *Conn) WithReader(ctx context.Context, timeout time.Duration, fn func(rd *proto.Reader) error,) error {if timeout >= 0 {if err := cn.netConn.SetReadDeadline(cn.deadline(ctx, timeout)); err != nil {return err}}return fn(cn.rd)}func (cn *Conn) deadline(ctx context.Context, timeout time.Duration) time.Time {tm := time.Now()cn.SetUsedAt(tm)if timeout > 0 {tm = tm.Add(timeout)}if ctx != nil {deadline, ok := ctx.Deadline()if ok {if timeout == 0 {return deadline}if deadline.Before(tm) {return deadline}return tm}}if timeout > 0 {return tm}return noDeadline}
计算逻辑比较简单,取最小的截止时间 min(Context Deadline, Read Deadline)
不妨再深入底层,net
包的调用直接向下传递给 netFD
type conn struct { fd *netFD}// Read implements the Conn Read method.func (c *conn) Read(b []byte) (int, error) { if !c.ok() { return 0, syscall.EINVAL } return c.fd.Read(b)}// SetReadDeadline implements the Conn SetReadDeadline method.func (c *conn) SetReadDeadline(t time.Time) error { if !c.ok() { return syscall.EINVAL } return c.fd.setReadDeadline(t)}
netFD
是最终调用 poll.FD
相关的函数。从 poll.FD
的名字可以看出,它是调度器的一部分,也是文件描述符(fd)的封装。
poll.FD
通过 syscall.Read
读取数据,该调用为非阻塞的。如果 I/O 就绪,则将数据从内核缓存区拷贝到用户缓冲区,并返回拷贝的字节数。如果发生错误,则判断错误类型:
goroutine
自身挂起// Network file descriptor.type netFD struct {pfd poll.FD// ...}func (fd *netFD) Read(p []byte) (n int, err error) {n, err = fd.pfd.Read(p)runtime.KeepAlive(fd)return n, wrapSyscallError(readSyscallName, err)}type FD struct {}func (fd *FD) Read(p []byte) (int, error) {// ...if fd.IsStream && len(p) > maxRW {p = p[:maxRW]}for { // 通过 syscall.Read 读取数据n, err := ignoringEINTRIO(syscall.Read, fd.Sysfd, p) // 如果发生错误,则判断错误类型: // - EAGAIN 类型错误,内核缓冲区为空,未读取到任何数据 // - 其他错误,则返回给调用者if err != nil { n = 0 // 挂起前检查if err == syscall.EAGAIN && fd.pd.pollable() {if err = fd.pd.waitRead(fd.isFile); err == nil {continue}}}err = fd.eofError(n, err)return n, err}}func (pd *pollDesc) waitRead(isFile bool) error { return pd.wait('r', isFile) }func (pd *pollDesc) wait(mode int, isFile bool) error {if pd.runtimeCtx == 0 {return errors.New("waiting for unsupported file type")} // 挂起协程res := runtime_pollWait(pd.runtimeCtx, mode)return convertErr(res, isFile)}
在 I/O 就绪或超时,Golang 调度器将挂起的 goroutine
重新调入执行。
func convertErr(res int, isFile bool) error {switch res {case pollNoError: // I/O 就绪return nilcase pollErrClosing: // 连接关闭return errClosing(isFile)case pollErrTimeout: // 读写超时return ErrDeadlineExceededcase pollErrNotPollable:return ErrNotPollable}println("unreachable: ", res)panic("unreachable")}
调度器相关细节,后续再深入探讨。
conn
可读就会执行 go-redis 的 cmd.readReply
。连接创建时,conn
的读写操作被封装为 bufio.Reader
。
// ---------- internal/pool/conn.go--------type Conn struct {usedAt int64 // atomicnetConn net.Connrd *proto.Readerbw *bufio.Writerwr *proto.WriterInited boolpooled boolcreatedAt time.Time}func NewConn(netConn net.Conn) *Conn {cn := &Conn{netConn: netConn,createdAt: time.Now(),}cn.rd = proto.NewReader(netConn)cn.bw = bufio.NewWriter(netConn) // buffer writercn.wr = proto.NewWriter(cn.bw)cn.SetUsedAt(time.Now())return cn}// ---------- proto/reader.go--------package prototype Reader struct {rd *bufio.Reader}func NewReader(rd io.Reader) *Reader {return &Reader{rd: bufio.NewReader(rd), // buffer reader}}
在超过截止时间之前,内核缓冲区内的 reply
数据已就绪,cmd.readReply
就可以借助 bufio.Reader
通过一次或多次 Read
调用,将已就绪的数据从内核换冲突拷贝到用户缓冲区。否则, Read
调用就会因为超过截止时间返回 ErrDeadlineExceeded
。
最后,可以猜测为什么会出现本文开头的现象:
Read
所需的数据就绪并没有超过 1 ms后续同事配合一起调整 min idle conn 大小之后,相关延迟毛刺消失。
go-redis 超时控制说复杂也复杂,说简单也简单。相关参数集中起来,可以汇总成以下这张图:
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/08-20-2023/go-redis-connection-timeout.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
GOSSIP 是一种分布式系统中常用的协议,用于在节点之间传播信息,维护集群拓扑结构。通过 GOSSIP 协议,Redis Cluster 中的每个节点都与其他节点进行通信,并共享集群的状态信息,最终达到所有节点拥有相同的集群状态。
在 Redis Cluster 中,Slot 和 Node 是两个关键概念,用于实现数据分片和高可用性。它们分别代表以下内容:
区分两个概念是为了实现水平扩展,当集群需要扩展时,可以添加新的节点并将一部分槽分配给它。
GOSSIP 协议的核心作用也跟这两个概念强相关,通过 GOSSIP:
在大规模的集群中,节点的数量可能非常多,节点之间的通信变得非常复杂。由于 GOSSIP 的理解难度,当集群出现问题时,排查和复现问题的难度非常高。为了更好的理解 GOSSIP 协议,就需要有合适的策略将问题简化。
观察 Redis cluster 集群的拓扑,表现出高度的对称性。在数学中,如果一个问题具有对称性,可以利用该性质来简化计算或者找到更简洁的解决方案。利用对称性,可以对集群拓扑进行两次简化,假设集群节点数为 N:
最终将 GOSSIP 简化为如下拓扑,其中 Node B 是 GOSSIP 消息的发送方,Node A 是消息接收方:
从 Redis 源代码易知,GOSSIP 消息主要包括消息头(clusterMsg
)和消息体(clusterMsgData
)两部分,结构体定义如下:
// 集群消息的结构(消息头,header)typedef struct { char sig[4]; /* Siganture "RCmb" (Redis Cluster message bus). */ // 消息的长度(包括这个消息头的长度和消息正文的长度) uint32_t totlen; /* Total length of this message */ uint16_t ver; /* Protocol version, currently set to 0. */ uint16_t notused0; /* 2 bytes not used. */ // 消息的类型 uint16_t type; /* Message type */ // 消息正文包含的节点信息数量 // 只在发送 MEET 、 PING 和 PONG 这三种 Gossip 协议消息时使用 uint16_t count; /* Only used for some kind of messages. */ // 消息发送者的配置纪元 uint64_t currentEpoch; /* The epoch accordingly to the sending node. */ // 如果消息发送者是一个主节点,那么这里记录的是消息发送者的配置纪元 // 如果消息发送者是一个从节点,那么这里记录的是消息发送者正在复制的主节点的配置纪元 uint64_t configEpoch; /* The config epoch if it's a master, or the last epoch advertised by its master if it is a slave. */ // 节点的复制偏移量 uint64_t offset; /* Master replication offset if node is a master or processed replication offset if node is a slave. */ // 消息发送者的名字(ID) char sender[REDIS_CLUSTER_NAMELEN]; /* Name of the sender node */ // 消息发送者目前的槽指派信息 unsigned char myslots[REDIS_CLUSTER_SLOTS/8]; // 如果消息发送者是一个从节点,那么这里记录的是消息发送者正在复制的主节点的名字 // 如果消息发送者是一个主节点,那么这里记录的是 REDIS_NODE_NULL_NAME // (一个 40 字节长,值全为 0 的字节数组) char slaveof[REDIS_CLUSTER_NAMELEN]; char notused1[32]; /* 32 bytes reserved for future usage. */ // 消息发送者的端口号 uint16_t port; /* Sender TCP base port */ // 消息发送者的标识值 uint16_t flags; /* Sender node flags */ // 消息发送者所处集群的状态 unsigned char state; /* Cluster state from the POV of the sender */ // 消息标志 unsigned char mflags[3]; /* Message flags: CLUSTERMSG_FLAG[012]_... */ // 消息的正文(Body),包括 PING/PONG/UPDATE/MODULE/FAIL/PUBLISH 等类型 union clusterMsgData data;} clusterMsg;
POV 的是 clusterState
,结构体定义如下:
// 集群状态,每个节点都保存着一个这样的状态,记录了它们眼中的集群的样子。typedef struct clusterState { // 指向当前节点的指针 clusterNode *myself; /* This node */ // 集群当前的配置纪元,用于实现故障转移 uint64_t currentEpoch; // 集群当前的状态:是在线还是下线 int state; /* REDIS_CLUSTER_OK, REDIS_CLUSTER_FAIL, ... */ // 集群中至少处理着一个槽的节点的数量。 int size; /* Num of master nodes with at least one slot */ // 集群节点名单(包括 myself 节点) // 字典的键为节点的名字,字典的值为 clusterNode 结构 dict *nodes; /* Hash table of name -> clusterNode structures */ // ... // 负责处理各个槽的节点 // 例如 slots[i] = clusterNode_A 表示槽 i 由节点 A 处理 clusterNode *slots[REDIS_CLUSTER_SLOTS]; // .... } clusterState;
将抽象的结构体定义转换为更容易理解的图形:
再看 Redis 对 GOSSIP 消息的处理,消息头和消息体的处理是不一样的。消息头更新消息发送者槽位分配图,而消息体更新集群拓扑及故障转移状态
自 Redis 3.0 支持 Redis cluster
之后,集群管理的机制几乎没有太大变化。由于缺少理论的支持,社区也出现过集群管理相关的缺陷——集群槽分配不一致,(Issue #2969、Issue #3776、Issue #6339),但由于其中的复杂度,该问题并没有得到很好的解决,相关的的测试用例(21-many-slot-migration.tcl)一直没有启用。官方的临时解决方案是提供了问题检测和修复的命令行工具 redis-cli –cluster。
同样的问题,在我们的生产环境也数次出现,急需解决。根据本文上述的分析,回看槽位的更新逻辑
/* We rebind the slot to the new node claiming it if: * 1) The slot was unassigned or the new node claims it with a * greater configEpoch. * 2) We are not currently importing the slot. */if (server.cluster->slots[j] == NULL || server.cluster->slots[j]->configEpoch < senderConfigEpoch){ // ... if (server.cluster->slots[j] == curmaster) { newmaster = sender; migrated_our_slots++; } clusterDelSlot(j); clusterAddSlot(sender,j); clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG| CLUSTER_TODO_UPDATE_STATE| CLUSTER_TODO_FSYNC_CONFIG);}
可知两点:
槽位的归属总是跟 configEpoch 息息相关,要理解缺陷出现的原因,就一定要去理解 configEpoch 是怎么更新的。
检索 configEpoch 更新的逻辑可知,Redis 节点仅在以下情况更新自己的 config Epoch(操作总是 currentEpoch++; configEpoch = currentEpoch):
从节点晋升为主节点
当从节点晋升为新的主节点时,它会将自己的 configEpoch 设为当前集群的 currentEpoch(当前纪元)+ 1。新的主节点就拥有了一个独立且更高的 configEpoch,以表示它接管了原主节点的角色。
故障转移
当执行故障转移时,即使用 CLUSTER FAILOVER
命令时,从节点会请求成为新的主节点。currentEpoch 会增加1,更新为自己的 configEpoch,以表示集群配置的变更。
槽位迁移
当槽位迁移完成时,IMPORTING
的节点(接收槽位的节点)会在迁移完成后将 currentEpoch 增加 1 ,更新为自己的 configEpoch,以表示它接管了相应的槽位
configEpoch 冲突
当节点从 GOSSIP 消息中发现其他节点的 configEpoch 与其 configEpoch 冲突(相同)时。解决冲突的方式是,此节点与具有冲突纪元的其他节点(“发送方”节点)Node ID 字典序较小的节点,将 currentEpoch 增加 1,更新为自己的 configEpoch
当创建新集群时,所有节点都以相同的 configEpoch 开始(默认是0)。冲突解决函数可以让节点在启动时自动以不同的 configEpoch 结束。
总而言之,configEpoch 更新时,槽位归属并不总是更新;反之,槽位归属更新时,configEpoch 必然更新。
根据以上知识,侧重 configEpoch 与 槽位的更新重新调整 POV 更新 如下图:
在第三种情况下,Redis cluster 的集群管理操作总是有一定概率出现无法恢复的冲突。即
在 POV 中,如果旧的 Master 有一个已经迁出的槽位尚未被新 Master 认领,单独更新 configEpoch 之后,槽位将被旧 Master 的新 configEpoch 看守起来。
旧 Master 在将此槽位迁到新 Master 之后,其 configEpoch 可能再次增加。即,旧 Master 的 configEpoch 比新 Master 的 configEpoch 更大。新 Master 就无法认领该槽位。最终造成该槽位的归属错乱。
具体示例、解释可以参考 Pull Request #12336。
由于 Redis 高性能的要求,Redis 的分布式注定无法使用 Raft 等强一致的协议同步进行一致性协商。虽然 Redis cluster GOSSIP 较为复杂且缺少理论论证,仍然成为目前为止去中心化架构下的最佳选择(社区更偏爱去中心化,头部科技公司反之)。理解 Redis cluster GOSSIP 协议,是使用该架构开发者的必修课。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/07-04-2023/redis-cluster-gossip.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
假设单个有状态实例支撑 1 W QPS,如果有 500 个实例,你可能会期望它们能够支撑 500 * 1W = 500W QPS 的流量。然而,实际情况可能会更为复杂。在实际系统中,添加多个实例并不总是线性地提高性能。
数据通常按照键进行分片。当你将数据分布在多个实例上时,不同的键可能被映射到不同的实例。如果你的访问模式导致某些键频繁被访问,而其他键很少被访问,那么可能会导致某些实例负载过高,而其他实例相对空闲。例如,如果某些热点键(hot keys)集中在同一实例,该实例可能会成为瓶颈。最终超过系统的承载能力,导致系统崩溃或性能下降。
处理热点数据是分布式系统中的常见挑战之一,如果预先知道某些键可能成为热点,可以将这些键手动分片到不同的实例。这样可以避免多个热点集中在同一个实例,实现负载均衡。
当然,也有一些通用的解决方案,包括:
此三者的第一步是一致的,首先要识别哪些数据是热点。
如果你希望知道系统中哪些数据是频繁访问的,哈希表是一种有效的数据结构。你可以使用数据作为键,将访问次数作为值存储在哈希表中。每次访问数据项时,增加对应键的值。通过统计访问次数,你可以识别出热点数据。
当系统中的数据量很大时,哈希表的内存占用可能会成为一个问题。如果数据集非常庞大,可能需要考虑使用分布式存储或其他高效的数据结构来处理统计信息。
数据集庞大的场景,另外一个容易想到替代哈希表的选择是 LRU 缓存算法。
首先,LRU 算法因为是基于访问时间的顺序来进行缓存数据的淘汰,会相对较少淘汰热点数据,从而一定程度上减轻了 Hotkey 的影响。它没有识别出有效的热点数据,因此无法有效将热点数据均匀分散到多个实例。
其次,在缓存大小一定的前提下,LRU 算法的效果受数据集大小的影响。访问的数据集越大,效果越差。
最后,在极端要求的场景下,未优化的引入 LRU 会潜在带来延迟和性能方面的影响。
- 内存释放顺序导致的延迟增加
LRU 前:
请求到达——分配内存——返回结果——释放内存
LRU 后:
请求到达——分配内存——【缓存结果/淘汰内存】——返回结果
- 离散读写导致的锁冲突。
即使利用分片降低锁粒度,相比批量定时更新缓存,离线读写导致的锁冲突的概率仍然跟读写请求量正相关。当离散的将数据加入缓存,写入线程持有写锁时,其他线程无法获取读锁或者写锁,需要等待写锁释放,导致线程阻塞和上下文切换。
在大数据处理中,此类问题称之为:”Heavy hitter problem(Top K problem)”。类似的问题还有。在网络流量分析中,找出最常见的IP地址或协议可以帮助我们识别潜在的攻击或瓶颈。在广告领域,识别最热门的广告内容可以帮助优化广告投放和资源分配。
常见的解决 “heavy hitter” 问题的算法包括:
三个算法的 Golang 版本实现 对比来看:CMS 算法使用哈希算法,如果数据不够离散,准确度下降的厉害(CDN 场景哈希文件名是由 MD5 生成,自然不成问题);SS 算法相对来说更为稳定,虽然性能稍差,但可通过采样降低数据处理的量来降低性能的损耗。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/06-13-2023/heavy-hitter.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
连接池的作用显而易见:
但是,连接池配置众多,根据业务特征调整好连接池并不容易。
go-redis 连接池的配置参数包括:
- DialTimeout # Dial timeout for establishing new connections.- ReadTimeout # Timeout for socket reads. If reached, commands will fail with a timeout instead of blocking.- WriteTimeout # Timeout for socket writes. If reached, commands will fail with a timeout instead of blocking.- PoolFIFO # Type of connection pool. true for FIFO pool, false for LIFO pool.- PoolSize # Maximum number of socket connections.- PoolTimeout # Amount of time client waits for connection if all connections are busy before returning an error.- MinIdleConns # Minimum number of idle connections which is useful when establishing new connection is slow.- MaxIdleConns # Maximum number of idle connections.- ConnMaxIdleTime # ConnMaxIdleTime is the maximum amount of time a connection may be idle.- ConnMaxLifetime # Expired connections may be closed lazily before reuse.
DialTimeout
(拨号超时)用于指定建立网络连接的超时时间。当客户端尝试连接到服务端时,如果在 DialTimeout
指定的时间内无法建立连接,连接操作将超时失败。它通常包括域名解析、建立 TCP 连接等步骤的超时时间。
DialTimeout
设置过小,可能会导致服务由于无法成功建立连接,启动失败。尤其是使用 DNS 作为服务发现以及跨 IDC 调用的场景下。
go-redis 默认是 5 s。3~5 s 是比较合适的,可以直接使用默认值。
如果连接池的大小设置过小,无法满足应用程序的并发需求,可能会导致连接不足的问题,影响应用程序的性能和响应速度。
如果连接池的大小设置过大,最大连接总数超过服务端最大连接数。在业务请求峰值时,会出现新建连接失败导致的请求失败。
那怎么评估连接池大小呢?
假如请求服务端的平均延迟是 duration ms,客户端进程的峰值 QPS 是 qps。单个连接 1 秒(1000)能否处理的请求总数是 1000 / duration;同时,预留一定的 Buffer 连接数 buffer 给请求变慢或请求量因为需求变化增加等场景。那么合适的连接池大小为:
PoolSize = qps / (1000 / duration) + buffer
如果连接生存时间设置得过短,则可能频繁地创建和销毁连接,影响性能。此问题比较容易理解。
如果连接生存时间设置得过长,可能会导致连接过期或失效。举个极端的例子,不设置连接生存时间。
考虑以下场景
场景一:
服务端新版本发布。假如该服务有两个实例 A、B。考虑发布过程,首先,A 升级重启,连接全部请求到 B。然后,B 升级重启,连接全部回到 A。因为没有设置连接生存时间,调用 A 不出现错误的前提下,连接永远不均匀。场景二:
客户端到服务端短暂网络异常。假如该服务有两个实例 A、B,新建连接的机制是 Round Robin。考虑到 B 的网络异常,导致请求全部断开。然后客户端开始新建连接,到 B 的连接全部失败,最终连接池的中的连接全部连接到 A。因为没有设置连接生存时间,调用 A 不出现错误的前提下,连接永远不均匀。
go-redis 该设置默认关闭。为避免类似问题,连接生存时间一般建议配置为小时级,既避免频繁地创建和销毁连接,影响性能;同时也避免连接不均匀。
在连接池中连接到服务端每个实例的连接数大致均匀的前提下。客户端从连接池获取连接发起请求,本质来说是一个负载均衡的问题。常见的负载均衡算法包括:
很显然,go-redis 默认使用的 LIFO 并不在列。
LIFO 并不适合作为负载均衡算法的选择。因为 LIFO 会优先处理最近使用过的连接,这可能会导致某些服务实例负载过重,而其他的服务实例却得不到充分的利用。这种不均衡的分配会影响系统的可用性、性能和容错能力。
因此,在使用 go-redis 时,PoolFIFO 应永远设置为 true。
连接使用:获取/释放流程图
连接管理:连接状态机
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/06-05-2023/go-redis-connection-pool.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
当今的数据中心可能包含数万台计算机,具有巨大的总带宽需求。网络架构通常是由路由器和交换机等元件构成的一棵树,网络层次结构越靠上,设备越专业化、越昂贵。不幸的是,即使部署最高端的 IP 交换机/路由器,所得拓扑也只能支持网络边缘可用总带宽的 50%,同时仍然产生巨大的成本。数据中心节点之间的非均匀带宽,使应用程序设计复杂化,并限制了整个系统性能。
在本文中,我们展示了如何利用大量商用以太网交换机来支持由数万个元件组成的集群的全部总带宽。与商用计算机集群在很大程度上取代了更专业化的 SMP 和 MPP 类似,我们认为适当架构和互连的商用交换机,能以更低的成本提供比现有高端解决方案更高的性能。我们的方法不需要对终端主机网络接口、操作系统或应用程序进行任何修改;关键是,它完全后向兼容以太网、IP和TCP。
越来越多的专业知识使许多机构能够以经济高效的方式运用兆亿次浮点运算能力和兆字节存储能力。数万台 PC 组成的集群在最大机构中并不少见,在大学、研究实验室和公司中千节点集群日益普遍。重要应用类别包括科学计算、金融分析、数据分析和仓储以及大规模网络服务。
如今,大型集群中主要瓶颈通常是节点间通信带宽。许多应用程序必须与远程节点交换信息才能继续进行本地计算。例如,MapReduce 必须执行大量数据洗牌(shuffling),以传输 map 阶段输出,然后才能继续进行 reduce 阶段。在基于集群的文件系统上运行的应用程序通常需要远程节点访问才能继续执行其 I/O 操作。搜索引擎查询通常需要与集群中存储倒排索引的每个节点进行并行通信,以返回最相关的结果。甚至逻辑上不同的集群之间,通常也存在重要的通信需求,例如,从负责构建索引的站点更新各个执行搜索的集群的倒排索引时。互联网服务越来越多地采用面向服务的架构,检索单个网页可能需要与远程节点上运行的数百个单独子服务进行协调和通信。最后,并行科学应用程序的重大通信需求众所周知。
大型集群的通信矩阵有两种高层选择。一种选择是利用专用硬件和通信协议,如 InfiniBand 或 Myrinet。虽然这些解决方案可以伸缩到具有高带宽的数千个节点的集群,但它们不利用商用零件(因此更昂贵),并且与 TCP/IP 应用程序不兼容。第二种选择是利用商用以太网交换机和路由器来互连集群机器。这种方法支持熟悉的管理基础设施以及未修改的应用程序、操作系统和硬件。不幸的是,集群带宽不能很好的随着集群规模伸缩,并且实现最高水平带宽会随着集群规模呈非线性增长。
由于兼容性和成本原因,大多数集群通信系统遵循第二种方法。然而,在大型集群中,由于通信模式的不同,通信带宽可能会被超分使用。也就是说,连接到同一物理交换机的两个节点可能能够以全带宽(例如 1Gbps)进行通信,但在交换机之间移动,可能跨越多个层次结构层次,可用带宽可能会严重受限。解决这些瓶颈需要非商用解决方案,例如大型 10Gbps 交换机和路由器。此外,典型的沿着相互连接的交换机树的单路径路由,意味着整个集群的带宽受到通信层次结构根部可用带宽的限制。即使我们处于一个转折点,10Gbps 技术正在变得具有成本竞争力,最大的 10Gbps 交换机仍然产生巨大的成本,并且仍然限制了最大集群的整体可用带宽。
在这种情况下,本文的目标是设计一种数据中心通信架构,满足以下目标:
通过在胖树(fat-tree)结构中互连商用交换机,可以实现由数万个节点组成的集群的双工带宽。具体来说,我们的架构实例使用 48 端口以太网交换机,能够为多达 27,648 个主机提供全带宽。通过完全使用商用交换机,我们实现了比现有解决方案更低的成本,同时提供了更多的带宽。我们的解决方案不需要对终端主机进行任何更改,完全兼容 TCP/IP,只对交换机本身的转发功能进行适度修改。我们还预计,一旦 10 GigE 交换机在集群边缘商用,我们的方法将是唯一一种能够为大型集群提供全带宽的方法,因为目前没有任何更高速度以太网替代方案(无论成本多少)。即使更高速度以太网解决方案可用,它们最初也会以巨大的成本得到小的端口密度。
我们进行了一项研究,以确定当前数据中心通信网络的最佳实践。我们在这里关注利用以太网和 IP 的商用设计;我们在第 7 节讨论我们的工作与替代技术的关系。
当前典型的架构由两层或三层树形交换机或路由器组成。三层设计(见图 1)树的根部是核心层,中间是聚合层,树的叶子处是边缘层。两层设计只有核心和边缘层。通常,两层设计可以支持 5K 到 8K 台主机。由于我们的目标大约是 25,000 台主机,因此我们将注意力聚焦在三层设计上。树叶处的交换机具有一些 GigE 端口(48-288)以及一些 10 GigE 上行链路到一个或多个网络元件层,这些元件聚合和传输叶子交换机之间的数据包。在层次结构的更高层,有具有 10 GigE 端口(通常为 32-128)和显著交换能力的交换机来聚合边缘之间的流量。
术语“:交换机” 指代执行二层交换和三层路由的设备。
假设使用两种类型的交换机,它们分别代表端口密度和带宽方面的高端型。前者,用于树的边缘,是一个带有四个 10 GigE 上行链路的 48 端口 GigE 交换机。对于通信层次结构的更高层,我们考虑 128 端口 10 GigE 交换机。两种类型的交换机都允许所有直连的主机彼此相互通信。
许多数据中心设计引入超分作为降低设计总成本的手段。我们定义超分这个术语为终端主机之间最坏情况下可实现的总带宽与特定通信拓扑双工带宽之比。超分比为 1:1 表示所有主机可与任意其他主机以其网络接口的全带宽(例如,商用以太网设计中的 1 Gb/s)进行通信。超分值为 5:1 意味着只有 20% 的可用主机带宽可用于某些通信模式。典型设计超分比为 2.5:1 (400 Mbps) 至 8:1 (125 Mbps)。尽管对于 1 Gb/s 的以太网,可以实现超分比为 1:1 的数据中心,但正如我们在第 2.1.4 节中所述,这种设计的成本通常是令人望而却步的,即使对于中等规模的数据中心也是如此。当超越单台交换机时,为 10 Gb/s 的以太网实现双工带宽目前是不可能的。
在大型集群中,实现任意主机之间的全带宽需要一个具有多个核心交换机的“多根”树(见图 1)。这反过来需要多路径路由技术,例如 ECMP。目前,大多数企业核心交换机都支持 ECMP。如果不使用 ECMP,则仅使用单根核心的 1:1 超分的集群最大大小将受到限制,最多为 1,280 个节点(对应于单个 128 端口 10 GigE 交换机的带宽)。
为了利用多条路径,ECMP 对流进行静态 负载分割 。在进行分配决策时,并未考虑流带宽,即使是简单的通信模式也可能导致超分。此外,当前的 ECMP 实现将路径的多样性限制在 8-16 之间,通常比大型数据中心所需的高双工带宽多样性更少。此外,路由表条目标数量随着考虑的路径数量成倍增长,这增加了开销与查找延迟。
为大型集群构建网络互连的成本极大地影响了设计决策。正如我们上面所讨论的,超分通常是为了降低总成本。在这里,使用当前最佳实践,我们给出了不同数量主机和超分配置的粗略成本。假设每个边缘的 48 端口 GigE 交换机的成本为 $7,000,聚合和核心层中的 128 端口 10 GigE 交换机的成本为 $700,000。在这些计算中,不考虑布线成本。
图 2 绘制了以百万美元为单位的成本与 x 轴上终端主机总数之间的关系。每条曲线代表目标超分比。例如,连接 20,000 台主机,并在所有主机之间提供全带宽的交换硬件约为 $37M。3∶1 超分比的曲线绘制了连接终端主机的成本,任意终端主机之间通信可用的最大带宽将限制在约 330 Mbps。图中还包括,胖树架构超分比为 1:1 的交付成本,以进行比较。
总的来说,我们发现使用现有技术为大型集群提供高水平带宽会产生巨大成本,而且基于胖树的集群互连在以适中的成本提供可伸缩带宽方面潜力巨大。然而,在某种意义上,图 2 低估了在构建数据中心架构中使用最高端的组件的难度和成本。2008 年,10 GigE 交换机即将成为商用零件;GigE 与 10 GigE 交换机相比,每端口每比特/秒价格差约为 5 倍,并且这种差值还在继续缩小。为了探究历史趋势,在表 1 中展示了特定年份中使用可用的最高端的交换机所支持的最大集群配置的成本。历史研究数据来自于各高端 10 GigE 交换机供应商 2002 年、2004 年、2006 年和 2008 年的产品公告。
使用我们的发现构建当年技术能够支持的、超分比为 1:1 的、最大集群配置。表 1 显示了特定年份可用的最大 10 GigE 交换机;在核心和聚合层中使用这些交换机进行分层设计。表格还显示了该年份可用的最大商用 GigE 交换机;在胖树的所有层和分层设计的边缘层中使用这些交换机。
传统技术采用高端交换机支持的最大集群大小一直受到可用端口密度的限制,直到最近。此外,当 10 GigE 交换机最初可用时,高端交换机成本让人望而却步。请注意,我们对传统层次结构的计算比较慷慨,因为聚合层的商用 GigE 交换机直到最近才有必要的 10 GigE 上行链路。相比之下,基于胖树拓扑的集群具有很好的可伸缩性,总成本下降的更早且更快(因为它更早地遵循商用定价趋势)。此外,在胖树拓扑中也不需要高速上行链路。
最后,值得注意的是,今天,技术上不可能构建一个具有 27,648 个节点,节点之间仅有 10 Gbps 带宽的集群。另一方面,胖树交换架构采用近乎商用的 48 端口 10 GigE 交换机,产生超过 6.9 亿美元的成本。虽然在大多数情况下可能成本过高,但最重要的事实是,甚至不可能使用传统聚合与高端交换机构建这样一个配置,因为今天没有产品,甚至没有以太网标准用于速度超过10 GigE 的交换机。
今天,商用和非商用交换机之间的价格差提供了强大的动力,用许多小型商用交换机取代少量大型、昂贵的交换机构建大规模通信网络。五十年多前,电话交换机类似的趋势促使 Charles Clos 设计了一种网络拓扑,通过适当地互连较小的商用交换机为许多终端设备提供高水平带宽。
本文采用一种特殊的 Clos 拓扑称为胖树(fat-tree)来互连商用以太网交换机。我们将 k 元胖树组织如图 3 所示。有 k 个 pod ,每个 pod 包含两层 k/2 台交换机。下层的 k 端口交换机直接连接到 k/2 台主机。剩余的 k/2 个端口连接到层次结构中聚合层的 k 个端口中的 k/2 个。
有 (k/2)2 台 k 端口核心交换机。每个核心交换机都有一个端口连接到 k 个 pod 。任何核心交换机的第 i 个端口连接到 pod i,使得每个 pod 交换机中以 (k/2) 步幅的连续端口连接到核心交换机。一般来说,k 端口交换机构建的胖树支持 k3/4 个主机。在本文中,我们专注于 k = 48 及以下的设计。我们的方法可以推广到任意 k 值。
胖树拓扑的一个优点是,所有交换元件都是相同的,使我们能够利用廉价的商用部件来实现通信架构中的所有交换器。此外,胖树是 _可重排非阻塞的_,这意味着对于任意通信模式,都有一组路径饱和拓扑中终端主机的所有可用带宽。由于需要防止 TCP 流的数据包重排,在实践中实现 1:1 的超分配置比较困难
图 3 显示了 k = 4 的简单胖树拓扑。连接到同一台边缘交换机的所有主机形成自己的子网。因此,所有流向同一子网的流量都被交换,而所有其他流量都被路由。
例如,由 48 端口千兆交换机构建的胖树包括 48 个 pod,每个 pod 包含一个边缘层和一个汇聚层,每个汇聚层有 24 台交换机。每个 pod 中的边缘交换机分配 24 台主机。网络支持 27,648 台主机,由 1,152 个子网组成,每个子网 24 台主机。不同 pod 中的任意两个主机之间有 576 条等价路径。部署这种网络架构的成本为 $8.64M,而前面描述的传统技术为 $37M。
鉴于我们的目标网络架构,在本文的其余部分,我们解决在以太网部署中采用这种拓扑的两个主要问题。首先,IP/以太网通常在每个源和目标之间建立单一路由路径。即使是简单的通信模式,单一路径路由也会迅速导致胖树上行下行的瓶颈,严重限制整体性能。我们描述了简单的 IP 转发扩展,以有效利用胖树的高扇出可用性。其次,在大型网络中,胖树拓扑会增加布线的复杂性。在某种程度上,这种开销是胖树拓扑固有的,但在第6节中,我们将介绍减轻这种开销的封装和放置技术。最后,我们在 Click 中构建了第 3 节所述架构的原型。第 5 节中给出的初步性能评估证实了我们的方法在小规模部署中潜在的性能优势。
在本节中,我们描述了一种将商用交换机互连成胖树拓扑的架构。我们首先说明需要对路由表结构进行轻微修改的原因。然后我们描述如何为集群中的主机分配 IP 地址。接下来,我们引入两级路由查找的概念,以协助完成跨胖树多路径路由。然后介绍在每个交换机中填充转发表的算法。我们还描述了流分类和流调度技术作为多路径路由的替代方法。最后,我们介绍了一个简单的容错方案,并描述了该方案的热量和功耗特征。
为了实现最大化网络的双工带宽,需要将任何给定 pod 的输出流量尽可能均匀地分布在核心交换机之间。路由协议如 OSPF2 通常以跳数作为“最短路径”的度量标准,在 k 元胖树拓扑结构(参见 2.2 节)中,不同 pod 的任意两台主机之间有 (k/2)2 条这样的最短路径,但只能选择了一条。因此,交换机将发送到给定子网的流量集中到单个端口,即使存在其他具有相同成本的选择。此外,由于 OSPF 消息到达时间交错,可能只选择少数核心交换机,甚至只选择一个作为 pod 之间的中间链路。这将导致这些点严重拥塞,并且无法利用胖树中的路径冗余
OSPF-ECMP 等扩展,除了不可用在候选的交换机类别之外,还会导致所需前缀数量爆炸性增长。一个较低层的 pod 交换机中需要为其他每个子网存储 (k/2) 个前缀;总计 k∗(k/2)2 个前缀。
因此,我们需要一种简单、细粒度的方法,利用拓扑结构在 pod 之间进行流量扩散。交换机必须能够识别需要均匀分布的流量类别,并给予特殊处理。为此,我们提出使用两级路由表,根据目标 IP 地址的低位字节来传播输出流量(参见第 3.3 节)。
我们分配私有的 10.0.0.0/8 段内所有网络 IP 地址。我们遵循熟悉的四点形式,满足以下条件:pod 交换机的地址形式为 10.pod.switch.1,其中 pod 表示 pod 编号( [0,k-1] ),switch 表示该交换机在 pod 中的位置([0,k−1],从左到右,从下到上)。我们给出核心交换机的地址形式为 10.k.j.i,其中 j 和 i 表示交换机在 (k/2)2 核心交换机网格中的坐标(每个都包含在 [1,(k/2)] 内,从左上角开始)。
主机的地址位于所连接的 pod 交换机之后;主机的地址格式为:10.pod.switch.ID,其中ID 是主机在该子网中的位置([2,k/2+1],从左到右)。因此,每个下层交换机负责 k/2 台主机的 /24 子网(k < 256)。图 3 显示了这种寻址方案的示例,对应于 k = 4 的胖树。尽管这种使用方式相对浪费可用地址空间,但它简化了路由表的构建,如下所示。而且,这种方案可以伸缩到 420 万台主机。
为了提供第 3.1 节提出的均匀分布机制,我们修改路由表以允许两级前缀查找。主路由表中的每个条目都可能有一个额外的指针,指向一个由(后缀,端口)条目组成的小型二级表。如果一级前缀不包含任何二级后缀,则终止,并且二级表可以被多个一级前缀指向。主表中的项是左旋的(即,/m 前缀掩码为 1m032−m),而二级表中的项是右旋的(即, /m 后缀掩码为 032−m1m)。如果最长匹配前缀搜索得到非终止前缀,则在二级表中找到最长匹配后缀并使用。
两级结构会稍微增加路由表查找延迟,但硬件中前缀搜索的并行性应该只会带来很小的损耗(见下文)。因为这些表都非常小。如下图所示,任何 pod 交换机的路由表都不会超过 k/2 个前缀和 k/2 个后缀。
我们现在描述如何使用内容可寻址存储器(Content-Addressable Memory, CAM) 在硬件中实现两级查找。CAM 用于搜索密集型应用,在查找位模式的匹配时,比算法实现更快。CAM 可以在单个时钟周期内并行搜索所有条目。查找引擎使用一种特殊的 CAM,称为三元 CAM (Ternary CAM, TCAM)。除了匹配 0 和 1 之外,TCAM 还可以在特定位置存储 —don’t care 位,使得它适合存储变长前缀,例如路由表中的前缀。缺点是,CAM 的存储密度很低,非常耗电,而且每比特的成本很高。然而,在我们的架构中,路由表可以实现在一个相对较小的 TCAM 中(k 个条目,每个 32 位宽)。
图 5 显示了我们提出的两级查找引擎的实现。TCAM 存储地址前缀和后缀,又索引一个 RAM,该 RAM 存储下一跳 IP 地址和输出端口。我们在数值较小的地址中存储左旋(前缀)条目,在较大的地址中存储右旋(后缀)条目。我们对 CAM 的输出进行编码,以便输出具有数值最小匹配地址的条目。这满足了特定的二级查找应用的语义:当数据包的目标 IP 地址同时匹配一个左旋项和一个右旋项时,则选择左旋项。例如,使用图 5 中的路由表,一个目标 IP 地址为 10.2.0.3 的数据包与左旋条目 10.2.0.X 和右旋条目 X.X.X.3 匹配。数据包正确地转发到端口 0。而目标地址为 10.3.1.2 的数据包只匹配右旋 X.X.X.2,并在端口 2 上转发。
胖树的前两层交换机充当过滤流量扩散器;任何给定 pod 中的下层和上层交换机都具有该pod 中子网的终止前缀。因此,如果一个主机将一个数据包发送到另一个同一 pod 但不同子网的主机,那么该 pod 中的所有上层交换机都具有一个指向目标子网交换机的终止前缀。
对于所有其他输出的 pod 间流量,pod 交换机有一个默认 /0 前缀,带有一个与主机 ID(目标 IP 地址的最低有效字节)匹配的二级表。我们利用主机 ID 作为确定性熵的来源;它们将使流量均匀地上行分布到核心交换机的出口链路。这也将导致到同一主机的后续数据包遵循相同的路径,从而避免数据包重排。
在核心交换机中,我们为所有网络 ID 分配终止第一级前缀,每个前缀指向包含该网络的适当 pod。一旦数据包到达核心交换机,就只有一条链路到它的目标 pod,并且该交换机将包含该数据包的 pod 的终止 /16 前缀(10.pod.0.0/16, port)。一旦一个数据包到达它的目标 pod,接收的上层 pod 交换机也将包括一个(10.pod.switch.0/24,port)前缀,以将该数据包定向到其目标子网交换机,在那里它最终被交换到其目标主机。因此,流量扩散只发生在数据包传输的前半段。
设计分布式协议可以在每个交换机中增量地建立所需的转发状态。然而,为简单起见,假设一个完全了解集群互连拓扑的中央实体。这个中央路由控制负责静态地生成所有路由表,并在网络设置阶段将这些表加载到交换机中。动态路由协议还负责检测单个交换机的故障并执行路径故障转移(见第 3.8 节)。下面,我们总结了在 pod 和核心交换机上生成转发表的步骤。
在每个 pod 交换机中,我们为包含同一 pod 中的子网分配终止前缀。对于 pod 间流量,添加一个 /0 前缀和一个与主机 ID 匹配的二级表。算法 1 展示了为上层 pod 交换机生成路由表的伪代码。输出端口模数移位的原因是避免来自同一个主机、不同底层交换机的流量流向同一个上层交换机。
对于下层 pod 交换机,我们简单地省略了 /24 子网前缀步骤(第 3 行),因为该子网自己的流量被交换,并且pod 间和 pod 内流量应该在上层交换机之间均匀分割。
由于每个核心交换机连接到每个 pod(端口 i 连接到 pod i),因此核心交换机只包含指向其目标 pod 的终止 /16 前缀,如算法 2 所示。该算法生成的表大小与 k 成线性关系。网络中没有交换机包含超过 k 个一级前缀或 k/2 个二级后缀的表。
为了说明使用两级表的网络操作,我们给出一个数据包从源 10.0.1.2 到目标 10.2.0.3 的路由决策示例,如图 3 所示。首先,源主机的网关交换机(10.0.1.1)只有 /0 的第一级前缀匹配该数据包,因此会根据该前缀的二级表中的主机 ID 字节转发该数据包。在该表中,在该表中,数据包与 0.0.0.3/8 后缀匹配,该后缀指向端口 2 和交换机 10.0.2.1。交换机 10.0.2.1 也遵循相同的步骤,并转发到端口 3,连接到核心交换机 10.4.1.1。核心交换机将数据包与终止 10.2.0.0/16 前缀匹配,该前缀指向目标 pod 2 的端口 2 和交换机 10.2.2.1。这个交换机与目标子网属于同一个 pod,因此有一个终止前缀 10.2.0.0/24,该前缀指向负责该子网的交换机 10.2.0.1 的端口 0。从那里,标准切换技术将数据包传递到目标主机 10.2.0.3。
请注意,对于从 10.0.1.3 到另一个主机 10.2.0.2 的同时通信,传统的单路径 IP 路由将遵循与上述流程相同的路径,因为两个目标地都属于同一个子网。不幸的是,这将消除胖树拓扑的所有扇出优势。相反,我们的两级表查找允许交换机 10.0.1.1 基于两级表中的右旋匹配将第二条流转发到 10.0.3.1。
除了上述两级路由技术,我们还考虑了两种可选的动态路由技术,因为它们目前在一些商用路由器中可用[10,3]。我们的目标是量化这些技术的潜在好处,但承认它们会产生每个数据包的额外开销。重要的是,这些方案中维护的任何状态都是软性的,如果状态丢失,单个交换机可以回退到两级路由。
作为流量扩散到核心交换机的另一种方法,我们在 pod 交换机中执行流分类,并使用动态端口重分配,以克服可避免的局部拥塞情况(例如,当两条流竞争同一个输出端口时,即使另一个到目标具有相同成本的端口未使用)。我们将 流 定义为一系列的数据包,这些数据包具有相同头部字段子集(通常是源 IP 地址和目标 IP 地址、目标传输端口)。特别是 pod 交换机:
第 1 步是针对数据包重排序的措施,第 2 步是在流大小动态变化的情况下,保证上行端口的流公平分布。第 4.2 节更详细地描述了流分类器的实现和流分布启发式方法。
已有研究表明,网络流量的传输时间和突发长度分布呈长尾分布,其特征是很少长生命周期的大数据流(占大部分带宽),而有许多短生命周期的小数据流。本文认为路由大型流在确定网络可实现的双工带宽方面,起着至关重要的作用,因此需要进行特殊处理。在这种流管理的替代方法中,我们调度大数据流以尽量减少彼此的重叠。中央调度器根据网络中所有活动的大数据流的全局信息做出此选择。在这个初始设计中,我们仅考虑每台主机一次只有一条大数据流的情况。
与之前一样,边缘交换机最初会在本地将新流分配给负载最少的端口。然而,边缘交换机还会检测任何规模增长超过预定义阈值的输出流,并定期向指定所有活动大数据流的源和目标的中央调度器发送通知。这表示边缘交换机将该流放置非竞争路径的请求。
请注意,与第 3.6 节不同的是,该方案不允许边缘交换机独立地重新分配流的端口,无论其大小。中央调度器是唯一有权下令重新分配的实体。
中央调度器(可能是复制的)跟踪所有活动的大数据流,并试图为它们分配不冲突的路径。调度器维护网络中所有链路的布尔状态,表示它们是否可用来承载大数据流。
对于 pod 间流量,回想一下,网络中任意一对主机之间都有 (k/2)2 条可能的路径,每条路径对应一台核心交换机。当调度器收到新流的通知时,它线性搜索核心交换机,以找到对应路径组件不包含预留链路的交换机。一旦找到这样的路径,调度器将这些连接标记为保留,并通知源 pod 相关的下层和上层交换机及流选择的路径相对应的正确输出端口。对pod 间的大数据流执行类似的搜索;这次是通过上层 pod 交换机找到一条无竞争路径。调度器垃圾收集最后更新时间超过给定时间的流,清除它们的预留标记。请注意,边缘交换机不会阻塞并等待调度器执行该计算,但一开始会像处理其他流一样处理大数据流。
任意一对主机之间可用路径的冗余使得胖树拓扑具有容错能力。我们提出了一种简单的故障广播协议,该协议允许交换机在下游一两跳处绕过链路或交换机故障。
在该方案中,网络中的每台交换机都与其每个邻居维护一个双向转发检测会话(BFD),以确定链路或邻居交换机何时发生故障。从容错的角度来看,可以承受两类故障:(a) 在 pod 间的下层交换机和上层交换机之间,(b) 在核心交换机和上层交换机之间。显然,较低层的交换机故障将导致直接连接的主机断开连接;叶子上的冗余交换机元件是容忍这种故障的唯一方法。我们在这里描述链路故障,因为交换机故障会触发相同的 BFD 警报,并引发相同的响应。
当下层和上层交换机之间发生链路故障时,会影响三类流量:
当上层交换机与核心交换机之间发生链路故障时,会影响两类流量:
自然地,当故障链路和交换机恢复并重新建立 BFD 会话时,上述步骤将被反转以抵消其效果。此外,调整第 3.7 节的方案适应链路和交换机故障相对简单。调度器将任何被报告为 down 的链路标记为繁忙或不可用,从而取消任何包含它的路径的候选资格,最终大型流绕过故障路由。
除了性能和成本,数据中心设计的另一个主要问题是功耗。在数据中心中,构成互连网络较高层的交换机通常消耗数千瓦的电力,在一个大规模的数据中心中,互连网络的电力需求可达数百千瓦。几乎同样重要的是交换机的散热问题。企业级交换机产生大量的热量,因此需要专用的冷却系统。
在本节中,我们将分析我们架构中的电力需求和散热,并将其与其他典型方案进行比较。我们的分析基于交换机数据表中报告的数字,尽管我们承认,这些报告的值由不同的供应商以不同的方式测量得到,因此可能并不总是反映部署中的系统特征。
为了比较每类交换机的功率需求,我们在交换机可支持的总带宽(以 Gbps 为单位)对交换机的总功耗和散热进行了归一化。图 6 绘制了三个不同交换机模型的平均值。我们可以看到,当带宽归一化时,10 GigE 交换机(x 轴上的最后 3 个)每 Gbps 消耗大约是商用 GigE 交换机两倍的瓦数,耗散大约三倍的热量。
最后,我们还计算了一个可支持约 27k 台主机的互连线的预估总功耗和散热。在分层设计中,我们使用了 576 台 ProCurve 2900 边缘交换机和 54 台 BigIron RX-32 交换机(汇聚层 36 台,核心层 18 台)。胖树结构采用了 2880 台 Netgear GSM 7252 交换机。我们能够使用更便宜的 NetGear 交换机,因为我们在胖树互连中不需要 10 GigE 的上行链路(存在于 ProCurve)。图 7 显示,虽然我们的架构采用了更多的单台交换机,但功耗和散热都优于当前的数据中心设计,功耗降低 56.6%,散热减少 56.5%。当然, 实际的功耗和散热必须在部署时进行测量;我们把这样的研究留作正在进行的工作。
为了验证本文描述的通信架构,我们构建了一个简单的转发算法原型。使用 NetFPGA 实现了一个原型系统。NetFPGA 包含一个利用 TCAM 的 IPv4 路由器实现。如 3.4 节所述,我们适当地修改了路由表查找例程。我们的修改总共不到100行代码,并且没有引入可测量的额外查找延迟,支持我们的观点,即我们提出的修改可以合并到现有的交换机。
为了进行更大规模的评估,我们还使用 Click 构建了一个原型,这是本文评估的重点。。Click 是一个模块化的软件路由器架构,支持实验路由器设计的实现。Click 路由是一个由称为元件的数据包处理模块组成的图,这些模块执行路由表查找或递减数据包的 TTL 等任务。当连接在一起时,Click 元件可以在软件中执行复杂的路由功能和协议。
我们构建了一个新的 Click 元件,TwoLevelTable,它实现了 3.3 节中描述的两级路由表的思想。这个元件有一个输入,两个或多个输出。路由表的内容使用输入文件初始化,该文件给出了所有的前缀和后缀。对于每个数据包,TwoLevelTable 元件查找最长匹配的第一级前缀。如果该前缀是终止的,它将立即在该前缀的端口上转发数据包。否则,它将在二级表上执行右旋最长匹配后缀搜索,并在相应的端口上转发。
该元件可以取代 [21] 中提供的符合标准的 IP 路由器配置示例的中央路由表元件。我们生成了一个类似的 4 端口版本的 IP 路由器,在所有端口上增加了带宽限制元素,以模拟链路饱和容量。
为了提供 3.6 节中描述的流分类功能,我们来介绍具有一个输入、两个或多个输出的Click 元件流分类器的实现。根据输入报文的源 IP 地址和目标 IP 地址进行简单的流分类,使得相同源和目标的后续报文从同一个端口输出(避免报文乱序)。元件增加了一个目标,即最小化其最高负载和最低负载输出端口之间聚合流容量的差异。
即使预先知道各条流的大小,该问题也是 NP 难装箱优化问题的一个变体。然而,流的大小实际上是未知的,这使得求解问题更加困难。我们遵循算法 3 中概述的贪婪启发式算法。每隔几秒钟,如果需要,启发式尝试切换最多 3 条流的输出端口,以最小化其输出端口的聚合流容量的差异。
回想一下,FlowClassifier 元件是用于流量扩散的两级表的替代方案。使用这些元件的网络采用普通的路由表。例如,一台上层 pod 交换机的路由表中包含了分配给该 pod 的所有子网前缀。然而,此外,我们添加了一个 /0 前缀来匹配所有剩余的需要均匀向上扩散到核心层的 pod 间流量。所有仅与该前缀匹配的数据包都被定向到 FlowClassifier 的输入。该分类器试图根据所描述的启发式方法在其输出之间均匀地分配 pod 间输出流,其输出直接连接到核心交换机。核心交换机不需要分类器,路由表保持不变。
请注意,这个解决方案有软性状态,它不是正确性所必需的,仅用作性能优化。这种分类器偶尔会造成干扰,因为少数的流可能会周期性地重新排列,可能导致数据包ç重排。然而,它也能适应动态变化的数据流大小,并且从长远来看是“公平的”。
如第 3.7 节所述,我们实现了元件 FlowReporter,它驻留在所有边缘交换机中,检测大于给定阈值的输出流。它定期向中央调度器发送这些活跃大数据流的通知。
FlowScheduler 元件从边缘交换机接收活跃大数据流的通知,并试图为它们找到无竞争的路径。为此,它保存了网络中所有连接的二进制状态,以及先前放置的流的列表。对于任何新的大流,调度器都会在源主机和目标主机之间的所有等价路径中执行线性搜索,以找到路径组件都没有预留的路径。找到这样的路径后,流调度器将所有组件连接标记为预留,并向相关的 pod 交换机发送该流路径的通知。我们还修改了pod 交换机,以处理来自调度器的端口重新分配消息。
调度器维护两个主要的数据结构:网络中所有连接的二进制数组(总共 4∗k∗(k/2)2 条连接),以及先前放置的流及其分配的路径的哈希表。搜索新的流布局平均需要 2 * (k / 2)2 次内存访问,使得调度器的空间复杂度为 O(k3),时间复杂度为 O(k2)。k 的典型值(每台交换机的端口数)为 48,使这两个值都可以管理,如第 5.3 节中所量化。
为了测量该设计的总双工带宽,生成了一套通信映射的基准套件,以评估使用 TwoLevelTable 交换机、FlowClassifier 和 FlowScheduler 的 4 端口胖树的性能。我们将这些方法与标准分层树进行了比较,其超分比为 3.6:1,类似与当前数据中心设计
在 4 端口胖树中,有 16 台主机、4 个 pod(每个 pod 有 4 台交换机)和 4 台核心交换机。因此,总共有 20 台交换机和 16 台终端主机(对于更大的集群,交换机的数量将小于主机的数量)。我们将这 36 个元件复用到 10 台物理机器上,由一条具有 1 Gigabit 以太网链路的 48 端口 ProCurve 2900 交换机连接。这些机器有 2.33GHz 的双核 Intel Xeon cpu, 4096KB 缓存和 4GB RAM,运行 Debian GNU/Linux 2.6.17.3。每台 pod 交换机托管在一台机器上;每个 pod 的主机都托管在一台机器上;剩下的两台机器分别运行两台核心交换机。交换机和主机都是 Click 配置,运行在用户级别。网络中所有 Click 元件之间的虚拟链路带宽限制为 96Mbit/s,以确保配置不受 CPU 限制。
分层树形网络的对比情况,有 4 台机器,每台机器运行 4 台主机,每台机器运行 4 台 pod 交换机,并有一条额外的上行链路。4 台 pod 交换机连接到运行在专用机上的 4 端口核心交换机。为了实现从 pod 交换机到核心交换机的上行链路 3.6:1 超分配置,这些链路的带宽被限制为 106.67Mbit/s,所有其他链路的带宽都被限制为 96Mbit/s。
每台主机输出的流量恒定为 96Mbit/s。我们测量输入流量的速率。对于所有的双向通信映射,所有主机的最小输入流量总和就是网络的有效双工带宽。
我们根据以下策略生成通信对,并增加限制,即任何主机仅接收来自一台主机的流量(即,映射为 1 对 1):
表 2 显示了上述实验的结果。这些结果是基准测试 5 次运行/排列的平均值,每次持续 1 分钟。如预期的那样,对于任何 pod 间通信模式,传统树会饱和到核心交换机的链路,因此在这种情况下,所有主机的实际带宽约为理想带宽的 28%。通信对彼此间越接近,树的性能越好。
两级表交换机在随机通信模式下实现了理想双工带宽的大约 75%。这可以用表的静态性质来解释;任何给定子网上的两台主机有 50% 的几率发送到具有相同主机 ID 的主机,在这种情况下,它们的总吞吐量减半,因为它们都被转发到同一输出端口。使得两者的期望值都为 75%。预计随着 k 的增加,两级表的随机通信性能会提高,因为随着 k 的增加,多条流在单个链路上发生碰撞的可能性会降低。两级表的内部流入情况给出了 50% 的双工带宽;然而,相同 ID 输出效应进一步被核心路由器中的拥塞所加剧。
由于动态流分配和重新分配,流分类器在所有情况下都优于传统树和两级表,最坏情况下双工带宽约为 75%。然而,它仍然不完美,因为它避免的拥塞类型完全是局部的;由于上游一两跳处所做的路由决策,可能会在核心交换机处造成拥塞。这种次优路由产生是因为交换机仅本地知识可用。
另一方面,FlowScheduler 基于全局知识并尝试将大数据流分配到不相交的路径上,从而在随机通信映射中实现了理想双工带宽的 93%,并在所有基准测试中都优于所有其他方案。使用具有所有活跃大数据流和所有连接状态知识的集中调度,对于大型任意网络可能是不可行的,但是胖树拓扑的规律性大大简化了寻找无冲突路径的过程。
在另一个测试中,表 3 显示了在配置适当的 2.33 GHz 商用 PC 上运行中央调度程序时的时间和空间要求。对于不同的 k,我们生成了虚假的放置请求(每台主机一个),以测量处理放置请求的平均时间和维护连接状态和流状态数据结构所需的总内存。对于一个包含27k 台主机的网络,调度程序需要 5.6MB 的内存,并且可以在不到 0.8ms 的时间内放置一条数据流。
胖树拓扑用于集群互连的一个缺点是需要大量的电缆来连接所有的机器。使用 10 GigE 交换机进行聚合的一个微不足道的好处是,向上层传输相同带宽所需电缆数量减少 10 倍。在我们提出的胖树拓扑中,既不利用 10 GigE 链路也不利用交换机,因为非商用部件会增加成本,更重要的是,因为胖树拓扑严重依赖于层次中每层多台交换机的大扇出来实现其伸缩性能。
承认增加布线开销是胖树拓扑固有的,在本节中,我们考虑一些组装技术来减轻这种开销。总之,我们提出的组装技术消除了大部分所需的外部布线,并减少了所需电缆的总长度,从而简化了集群管理并降低了总成本。此外,这种方法允许网络的增量部署。
在最大容量 27,648 节点集群的背景下,提出了我们的方法,该集群利用 48 端口以太网交换机作为胖树的构建模块。这种设计可以推广到不同大小的集群。我们从单个 pod 的设计开始,它们构成了大型集群的复制单元,见图 8。每个 pod 包括 576 台计算机和 48 个独立 48 端口 GigE 交换机。为简单起见,假设每台终端主机占用一个机架单元(1RU),并且单个机架可以容纳 48 台计算机。因此,每个 pod 由 12 个机架组成,每个机架有 48 台计算机。
将构成 pod 的、胖树前两层的 48 台交换机放置在一个集中的机架中。但是,假设能够将48 台交换机打包成一个单一的整体单元,具有 1,152 个面向用户的端口。我们称之为 pod 交换机。其中 576 个端口直接连接到 pod 中的计算机,对应于边缘连接。另外 576 个端口扇出到胖树核心层中 576 台交换机中的一个端口。请注意,以这种方式打包的 48 台交换机实际上具有 2,304 个总端口(48 * 48)。另外 1,152 个端口在 pod 交换机内部接线,以解决 pod 边缘和聚合层之间所需的互连(见图 3)。
进一步将组成胖树顶部的 576 台必需核心交换机分布在各个 pod 中。假设总共有 48 个 pod ,每个 pod 将容纳 12 台必需的核心交换机。从每台 pod 交换机扇出到核心层的 576根电缆中,有 12 根将直接连接到放置在同一 pod 的核心交换机上。其余电缆每 12 一组扇出到远程 pod 中的核心交换机。请注意,电缆每 12 一组从 pod 移动到 pod,并且以 每 48 一组从机架移动到 pod 交换机,这为适当的“电缆封装”提供了额外的机会,以减少布线的复杂性。
最后,最小化电缆总长度也是一个重要的考虑因素。为此,围绕 pod 交换机在两个维度上放置机架,如图 8 所示(我们不考虑三维数据中心布局)。相比于在一个 pod 中“水平” 布局的单个机架,这样做将减少电缆长度。同样,将 pod 布置在 7×7 的网格中(空缺一个位置)以容纳所有 48 个 pod 。再次,这种网格布局将减少 pod 间布线到适当核心交换机的距离,,并将支持电缆长度和包装的一些标准化,以支持 pod 间的连接。
我们还考虑了一种不将交换机集中到一个机架中的替代设计。在这种方法中,每个机架将分配两台 48 端口交换机。主机每 24 一组连接到交换机。这种方法的优点是主机连接到第一跳交换机所需的电缆更短,并且如果机架适当的内部封装,可以完全消除这些电缆。我们放弃了这种方法,因为我们会失去消除每个 pod 内连接边缘层和聚合层的 576 根电缆的机会。这些电缆需要穿过每个 pod 的 12 个机架,大大增加了复杂性。
我们在数据中心网络架构方面的工作必然建立在许多相关领域的工作基础上。也许与我们的努力最密切相关的是建立可伸缩互连的各种努力,主要来自超级计算机和大规模并行处理(MPP)社区。许多 MPP 互连都组织成胖树,包括 Thinking Machines 和 SGI 的系统。Thinking Machine 采用伪随机转发决策来执行胖树连接之间的负载平衡。虽然这种方法实现了良好的负载平衡,但它容易发生数据包重排。Myrinet 交换机也采用胖树拓扑,并且一直受到基于集群的超级计算机的欢迎。Myrinet 采用基于预定拓扑知识的源路由,启用直通低延迟交换机实现。主机还负责通过测量往返延迟来在可用路由之间进行负载均衡。相对于所有这些工作,我们专注于利用商用以太网交换机来互连大规模集群,展示适当的路由和封装技术。
InfiniBand 是高性能计算环境中流行的互连,并且目前正在迁移到数据中心环境。 InfiniBand 还使用 Clos 拓扑的变体来实现可伸缩带宽。例如,Sun 最近宣布了一款 3,456 端口 InfiniBand 交换机,该交换机由 720 台 24 端口 InfiniBand 交换机组成,排列成 5 级胖树。但是,InfiniBand 强加了自己的 1-4 层协议,使得 以太网/IP/TCP 在某些设置中更具吸引力,特别是随着 10Gbps 以太网价格的不断下降。
另一个流行的 MPP 互连拓扑是 Torus,例如 BlueGene/L 和 Cray XT3。Torus 直接将处理器与 k 维格子中的一些邻居相互连接。维数决定了源和目标地之间预期的跳数。在 MPP 环境中,Torus 的优点是没有任何专用的交换元件,以及电气上更简单的点对点连接。在集群环境中,Torus 的布线复杂性很快变得难以承受,并且卸载所有路由和转发功能到商用主机/操作系统通常是不切实际的。
我们提出的转发技术与现有的路由技术,如 OSPF2 和等价多路径(ECMP)相关。我们提出多路径利用胖树拓扑的特定属性来实现良好性能。相对于我们的工作,ECMP 提出了三类无状态转发算法:(i)轮询和随机化;(ii)区域拆分,其中特定前缀被拆分为两个较大掩码长度的前缀;以及(iii)一种散列技术,它根据源地址和目标地址将流拆分到一组输出端口。第一种方法会遇到潜在的数据包重排问题,对 TCP 尤其有问题。第二种方法可能导致路由前缀数量激增。在具有 25,000 台主机的网络中,需要大约 600,000 个路由表条目。除了增加成本外,这种规模的表查找也会产生巨大延迟。因此,当前企业级路由器最多允许 16 路 ECMP 路由。最后一种方法在进行分配决策时不考虑流带宽,即使简单的通信模式也会很快超分。
带宽越来越成为大规模集群可伸缩性的瓶颈。现有解决这一瓶颈的解决方案围绕着交换机层次结构,顶层的交换机昂贵,非商用化。在任何给定时间点,高端交换机的端口密度都会限制整个集群的大小,同时产生高昂的成本。在本文中,我们提出了一种数据中心通信架构,利用商用以太网交换机为大规模集群提供可伸缩带宽。以胖树为基础构建拓扑,然后提出技术来执行可伸缩路由,同时保持与以太网、IP 和 TCP 的后向兼容性。
总体而言,我们发现我们能够以比现有技术显著更低的成本提供可伸缩带宽。虽然还需要进一步的工作来完全验证我们的方法,但我们相信更多的商用交换机有可能在数据中心取代高端交换机,就像商用 PC 集群取代了高端计算环境中的超级计算机一样。
原文: A Scalable, Commodity Data Center Network Architecture
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/05-10-2023/a-scalable-commodity-data-center-network-architecture-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
本文是对我们之前的文章监控和调优 Linux 网络堆栈:接收数据的扩展,其中包含了一系列旨在帮助读者更清晰地了解 Linux 网络堆栈工作原理的图表。
在监控或调优 Linux 网络堆栈时,没有捷径可走。运维人员必须努力全面了解各个系统及其相互作用,才有可能对它们进行调优。也就是说,之前博客文章的长度可能使读者难以概念化各个系统如何相互作用。希望这篇博客文章能够帮助澄清这一点。
这些图表旨在概述 Linux 网络堆栈的工作原理。因此,许多细节被省略了。为了获得完整的画面,建议读者阅读我们的博客文章,其中详细介绍了网络堆栈的各个方面:监控和调优 Linux 网络堆栈:接收数据。这些图的目的是帮助读者形成一个心智模型,了解内核中的一些系统如何在高层次上相互交互。
让我们首先看一下在理解数据包处理之前必要的一些重要初始设置。
设备有许多方法来提醒计算机系统的其他部分,有一些工作已经准备好进行处理。在网络设备的情况下,NIC 常常会产生一个 IRQ 来表示一个数据包已经到达并准备好被处理。当 Linux 内核执行 IRQ 处理程序时,它以非常高的优先级运行,并且通常阻止生成其他的 IRQ。因此,设备驱动程序中的 IRQ 处理程序必须尽快执行,并推迟所有长时间运行的工作到在此上下文之外执行。这就是“softIRQ”系统存在的原因。
Linux 内核的“softIRQ”系统是内核用来在设备驱动程序 IRQ 上下文之外处理工作的系统。在网络设备的情况下,softIRQ 系统负责处理传入的数据包。softIRQ 系统在内核启动过程的早期初始化。
上图对应文章的 softIRQ 部分,显示了 softIRQ 系统及其每个 CPU 内核线程的初始化过程。
softIRQ 系统的初始化如下:
smpboot_register_percpu_thread
,在 kernel/softirq.c 中的 spawn_ksoftirqd
中创建 softIRQ 内核线程(每个 CPU 一个)。如代码所示,函数 run_ksoftirqd
被列为 thread_fn
,这是将在循环中执行的函数。run_ksoftirqd
函数中执行它们的处理循环。softnet_data
结构。这些结构保存着对处理网络数据的重要数据结构的引用。poll_list
后续将再次看到。poll_list
是调用 napi_schedule
或来自设备驱动程序的其他 NAPI API 来添加 NAPI poll worker 结构的地方。net_dev_init
调用 open_softirq
向 softirq 系统注册 NET_RX_SOFTIRQ
softirq,如此处所示。注册的处理程序函数称为 net_rx_action
。这是 softirq 内核线程为处理数据包而执行的函数。图上的步骤 5 - 8 与到达的数据处理有关,并将在下一节中提及。继续阅读以获取更多信息!
当网络数据到达 NIC 时,NIC 将使用 DMA将数据包数据写入 RAM。在 igb
网络驱动程序的情况下,RAM 中设置了一个指向接收数据包的环形缓冲区。需要注意的是,一些 NIC 是“多队列” NIC,这意味着它们可以 DMA 传入的数据包到 RAM 中的多个环形缓冲区之一。正如我们很快就会看到的,这样的 NIC 能够利用多个处理器来处理传入的网络数据。阅读有关多队列 NIC 的更多信息。上图为了简单起见只显示了一个环形缓冲区,但根据您使用的 NIC 和硬件设置,您的系统上可能有多个队列。
阅读更多关于下面描述过程的详细信息在此部分网络博客文章中。
让我们来看一下接收数据的过程:
napi_schedule
启动 NAPI softIRQ 轮询循环。调用 napi_schedule
触发了前面图表中步骤 5 - 8 的开始。正如我们将看到的,NAPI softIRQ 轮询循环的启动仅仅是在位域中翻转一个位,并将一个结构添加到 poll_list
中进行处理。napi_schedule
不做任何其他工作,这正是驱动程序如何将处理推迟到 softIRQ 系统的方式。
继续前一节中的图表,使用那里找到的数字:
napi_schedule
添加驱动程序的 NAPI 轮询结构到当前 CPU 的 poll_list
中。ksoftirqd
进程知道有数据包要处理。run_ksoftirqd
函数(由 ksoftirq
内核线程在循环中运行)。__do_softirq
,检查挂起位域,看到 softIRQ 挂起,并调用挂起 softIRQ 的已注册处理程序:net_rx_action
,它完成了传入网络数据处理的所有繁重工作。需要注意的是,执行 net_rx_action
的是 softIRQ 内核线程,而不是设备驱动程序 IRQ 处理程序。
现在,数据处理开始。net_rx_action
函数(从 ksoftirqd
内核线程调用)开始处理已添加到当前 CPU 的 poll_list
中的任何 NAPI 轮询结构。通常在两种情况下,添加轮询结构:
napi_schedule
。我们将从 poll_list
中获取驱动程序的 NAPI 结构开始。 (下一节介绍 RPS 如何使用 IPI 注册 NAPI 结构)。
上面的图表在这里进行了深入的解释,可以总结如下:
net_rx_action
循环开始检查 NAPI 轮询列表中的 NAPI 结构。budget
和经过的时间以确保 softIRQ 不会垄断 CPU 时间。poll
函数。在这种情况下,igb_poll
函数由 igb
驱动程序注册。poll
函数从 RAM 中的环形缓冲区收取数据包。napi_gro_receive
,它将处理可能的通用接收卸载。net_receive_skb
,继续向协议栈上方进行。接下来我们将看到 net_receive_skb
如何处理 Receive Packet steering,以在多个 CPU 之间分配数据包处理负载。
网络数据处理从 netif_receive_skb
继续,但数据的路径取决于是否启用了 Receive Packet Steering (RPS)。一个“开箱即用”的 Linux 内核默认不会启用 RPS,如果您想使用它,需要显式启用并配置。
在禁用 RPS 的情况下,使用上图中的数字:
netif_receive_skb
将数据传递给 __netif_receive_core
。__netif_receive_core
将数据传递给任何 tap(如PCAP)。__netif_receive_core
将数据传递给已注册的协议层处理程序。在许多情况下,是 IPv4 协议栈已注册的 ip_rcv
函数。netif_receive_skb
将数据传递给 enqueue_to_backlog
。poll_list
中,并排队一个 IPI,如果远程 CPU 上的 softIRQ 内核线程尚未运行,则触发它唤醒。ksoftirqd
内核线程运行时,它遵循前一节中描述的相同模式,但这次,已注册的 poll
函数是 process_backlog
,它从当前 CPU 的输入队列中收取数据包。__net_receive_skb_core
。__netif_receive_core
将数据传递给任何 tap(如PCAP)。__netif_receive_core
将数据传递给已注册的协议层处理程序。在许多情况下,是 IPv4 协议栈已注册的 ip_rcv
函数。接下来是协议栈、netfilter、berkley packet filters,最后是用户空间套接字。这条代码路径很长,但线性且相对简单。
您可以继续跟踪网络数据的详细路径。一个非常简短的高层次总结路径是:
ip_rcv
接收到 IPv4 协议层。udp_rcv
接收到 UDP 协议层,并由 udp_queue_rcv_skb
和 sock_queue_rcv
排队到用户空间套接字的接收缓冲区。在排队到接收缓冲区之前,处理伯克利数据包过滤器。请注意,在此过程中多次咨询 netfilter。确切的位置可以在我们的详细演练中找到。
Linux 网络堆栈非常复杂,有许多不同的系统相互作用。任何调优或监控这些复杂系统的努力都必须努力理解它们之间的相互作用以及如何更改一个系统中的设置会影响其他系统。
这篇(画得不好的)博客文章试图使我们的更长的博客文章更易于管理和理解。
原文: Illustrated Guide to Monitoring and Tuning the Linux Networking Stack: Receiving Data
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/04-26-2023/illustrated-guide-to-monitoring-and-tuning-the-Linux-networking-stack-recv-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
本文解释了 Linux 内核的计算机如何发送数据包,以及当数据包从用户程序流向网络硬件时,如何监控和调优网络栈的每个组件。
本文是之前的文章 监控和调优 Linux 网络栈:接收数据 的姊妹篇。
如果不阅读内核的源代码,不深入了解到底发生了什么,就不可能调优或监控 Linux 网络栈。
希望本文能给想做这方面工作的人提供参考。
正如在上一篇文章中提到的,Linux 网络栈是复杂的,没有一刀切的监控或调优解决方案。 如果您真的想调优网络栈,您别无选择,只能投入大量的时间、精力和金钱来了解网络系统的各个部分是如何交互的。
本文中提供的许多示例设置仅用于说明目的,并不是对某个配置或默认设置的推荐或反对。 在调整任何设置之前,您应该围绕您需要监控的内容制定一个参考框架,以注意到有意义的变化。
网络连接到计算机时调整网络设置是危险的;你很容易地把自己锁在外面,或者完全关闭你的网络。 不要在生产机器上调整这些设置;相反,如果可能的话,在新机器上进行调整,再投入生产中。
作为参考,您可能需要手边有一份设备数据手册。 这篇文章将研究由 igb
设备驱动程序控制的 Intel I350 以太网控制器。 您可以找到该数据手册(警告:大型 PDF)供您参考。
网络数据从用户程序到网络设备的流程概览:
sendto
、sendmsg
等)写入数据。AF_INET
)。NET_TX
软中断期间发送。NET_RX
软中断,触发 NAPI 轮询循环开始运行。接下来各节会详细介绍以上整个流程。
下面探讨的协议层是 IP 和 UDP 协议层。 本文介绍的许多信息也可作为其他协议层的参考。
与姊妹篇类似,本文将探讨 Linux 3.13.0 版本内核,贯穿全文提供了 GitHub 代码链接和代码片段。
从如何在内核中注册协议族、套接字子系统如何使用协议族开始探讨,然后探讨协议族接收数据。
当用户程序中运行这样一段代码来创建 UDP 套接字时,会发生什么?
sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
简而言之,Linux 内核查找 UDP 协议栈导出的一组函数,它们处理包括发送和接收网络数据在内的许多事情。 要准确理解其工作原理,必须深入 AF_INET
地址族代码。
Linux 内核在内核初始化的早期执行 inet_init
函数。 此函数注册 AF_INET
协议族、协议族中的各种协议栈(TCP、UDP、ICMP 和 RAW),并调用初始化程序使协议栈准备好处理网络数据。 您可以在 ./net/ipv4/af_inet.c 中找到 inet_init
的代码。
AF_INET
协议族导出了一个具有 create
函数的结构。 当用户程序创建套接字时,内核会调用此函数:
static const struct net_proto_family inet_family_ops = { .family = PF_INET, .create = inet_create, .owner = THIS_MODULE,};
inet_create
函数接受传递给套接字系统调用的参数,搜索已注册的协议,以找到链接到套接字的一组操作。 看一看:
/* Look for the requested type/protocol pair. */lookup_protocol: err = -ESOCKTNOSUPPORT; rcu_read_lock(); list_for_each_entry_rcu(answer, &inetsw[sock->type], list) { err = 0; /* Check the non-wild match. */ if (protocol == answer->protocol) { if (protocol != IPPROTO_IP) break; } else { /* Check for the two wild cases. */ if (IPPROTO_IP == protocol) { protocol = answer->protocol; break; } if (IPPROTO_IP == answer->protocol) break; } err = -EPROTONOSUPPORT; }
稍后,复制 answer
的 ops
字段到套接字结构中,answer
持有协议栈相关的引用:
sock->ops = answer->ops;
可以在 af_inet.c
中找到所有协议栈的结构定义。 让我们看一下TCP 和 UDP 协议结构:
/* Upon startup we insert all the elements in inetsw_array[] into * the linked list inetsw. */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, }, { .type = SOCK_DGRAM, .protocol = IPPROTO_UDP, .prot = &udp_prot, .ops = &inet_dgram_ops, .no_check = UDP_CSUM_DEFAULT, .flags = INET_PROTOSW_PERMANENT, },/* .... more protocols ... */
在 IPPROTO_UDP
的情况下,ops
结构关联包含各种功能的函数,包括发送和接收数据:
const struct proto_ops inet_dgram_ops = { .family = PF_INET, .owner = THIS_MODULE, /* ... */ .sendmsg = inet_sendmsg, .recvmsg = inet_recvmsg, /* ... */};EXPORT_SYMBOL(inet_dgram_ops);
协议相关的结构 prot
包含函数指针,指向 UDP 协议栈所有内部函数。UDP 协议中,此结构被称为 udp_prot
,并由 ./net/ipv4/udp.c 导出:
struct proto udp_prot = { .name = "UDP", .owner = THIS_MODULE, /* ... */ .sendmsg = udp_sendmsg, .recvmsg = udp_recvmsg, /* ... */};EXPORT_SYMBOL(udp_prot);
现在,转向一段发送 UDP 数据的用户程序,看内核是如何调用 udp_sendmsg
的!
用户程序想要发送 UDP 网络数据,因此它使用 sendto
系统调用,可能像这样:
ret = sendto(socket, buffer, buflen, 0, &dest, sizeof(dest));
此系统调用经过Linux 系统调用层,并落在./net/socket.c
中的这个函数:
/* * Send a datagram to a given address. We move the address into kernel * space and check the user space data area is readable before invoking * the protocol. */SYSCALL_DEFINE6(sendto, int, fd, void __user *, buff, size_t, len, unsigned int, flags, struct sockaddr __user *, addr, int, addr_len){/* ... code ... */err = sock_sendmsg(sock, &msg, len);/* ... code ... */}
SYSCALL_DEFINE6
宏展开为一堆宏,这些宏反过来使用 6 个参数,建立基础结构来创建系统调用(因此是 DEFINE6
)。 这样做的一个结果是,内核的系统调用函数名都有 sys_
前缀。
sendto
的系统调用代码,组织数据为较低层能够处理的格式之后,调用 sock_sendmsg
。 特别是,它将传递给 sendto
的目标地址构造一个结构,让我们来看一下:
iov.iov_base = buff;iov.iov_len = len;msg.msg_name = NULL;msg.msg_iov = &iov;msg.msg_iovlen = 1;msg.msg_control = NULL;msg.msg_controllen = 0;msg.msg_namelen = 0;if (addr) { err = move_addr_to_kernel(addr, addr_len, &address); if (err < 0) goto out_put; msg.msg_name = (struct sockaddr *)&address; msg.msg_namelen = addr_len;}
此段代码复制用户程序传入的 addr
到内核数据结构 address
中,然后以 msg_name
嵌入到 struct msghdr
结构中。 类似于 userland 程序不调用 sendto
,而是直接调用 sendmsg
时所做的操作。内核提供此变化,是因为 sendto
和 sendmsg
都调用到 sock_sendmsg
。
sock_sendmsg
、__sock_sendmsg
和 __sock_sendmsg_nosec
在调用 __sock_sendmsg
之前,sock_sendmsg
会执行一些错误检查,而 __sock_sendmsg
在调用 __sock_sendmsg_nosec
之前也会进行自己的错误检查。__sock_sendmsg_nosec
传递数据到更深层的套接字子系统中。
static inline int __sock_sendmsg_nosec(struct kiocb *iocb, struct socket *sock, struct msghdr *msg, size_t size){ struct sock_iocb *si = ..../* other code ... */ return sock->ops->sendmsg(iocb, sock, msg, size);}
如前一节解释套接字创建时所述,注册到此套接字 ops
结构的 sendmsg
函数是inet_sendmsg
。
inet_sendmsg
从名字不难猜到,这是 AF_INET
协议族提供的一个通用函数。 此函数首先调用sock_rps_record_flow
记录最后一个处理流的 CPU;接收数据包转向会使用该信息。 接下来,查找并调用套接字的内部协议操作结构的 sendmsg
函数:
int inet_sendmsg(struct kiocb *iocb, struct socket *sock, struct msghdr *msg, size_t size){ struct sock *sk = sock->sk; sock_rps_record_flow(sk); /* We may need to bind the socket. */ if (!inet_sk(sk)->inet_num && !sk->sk_prot->no_autobind && inet_autobind(sk)) return -EAGAIN; return sk->sk_prot->sendmsg(iocb, sk, msg, size);}EXPORT_SYMBOL(inet_sendmsg);
在处理 UDP 时,sk->sk_prot->sendmsg
指向 UDP 协议层 udp_sendmsg
。 udp_sendmsg
是前面看到的 udp_prot
结构导出的。此函数调用从通用 AF_INET
协议族过渡到 UDP 协议栈。
udp_sendmsg
udp_sendmsg
函数位于 ./net/ipv4/udp.c。 整个函数相当长,因此我们将探讨其中的一些部分。 如果你想完整地阅读它,请点击前面的链接。
在变量声明和一些基本的错误检查之后,udp_sendmsg
要做的第一件事就是检查套接字是否“corked”。 UDP corking 是一项特性,允许用户程序请求内核累积多次 send
调用的数据到单个数据报中发送。 在用户程序中有两种方法可启用此选项:
setsockopt
系统调用,传递 UDP_CORK
套接字选项。send
、sendto
或 sendmsg
时,传递带有 MSG_MORE
的 flags
。以上选项分别记录在 UDP 手册页 和 send / sendto / sendmsg 手册页 。
udp_sendmsg
检查 up->pending
以确定套接字当前是否被 corked。如果是,则直接追加数据。 稍后将看到如何追加数据。
int udp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg, size_t len){/* variables and error checking ... */ fl4 = &inet->cork.fl.u.ip4; if (up->pending) { /* * There are pending frames. * The socket lock must be held while it's corked. */ lock_sock(sk); if (likely(up->pending)) { if (unlikely(up->pending != AF_INET)) { release_sock(sk); return -EINVAL; } goto do_append_data; } release_sock(sk); }
接下来,从两个可能的来源之一确定目标地址和端口:
sendto
的内核代码中看到的那样。内核处理逻辑如下:
/* * Get and verify the address. */if (msg->msg_name) { struct sockaddr_in *usin = (struct sockaddr_in *)msg->msg_name; if (msg->msg_namelen < sizeof(*usin)) return -EINVAL; if (usin->sin_family != AF_INET) { if (usin->sin_family != AF_UNSPEC) return -EAFNOSUPPORT; } daddr = usin->sin_addr.s_addr; dport = usin->sin_port; if (dport == 0) return -EINVAL;} else { if (sk->sk_state != TCP_ESTABLISHED) return -EDESTADDRREQ; daddr = inet->inet_daddr; dport = inet->inet_dport; /* Open fast path for connected socket. Route will not be used, if at least one option is set. */ connected = 1;}
是的,UDP 协议层使用 TCP_ESTABLISHED
! 不管怎样,套接字状态都使用 TCP 状态描述。
回想一下前面看到的,当用户程序调用 sendto
时,内核是如何代表用户组装一个 struct msghdr
结构。 上面的代码显示了内核解析该数据设置 daddr
和 dport
。
当内核函数访问 udp_sendmsg
函数时,内核函数没有构造 struct msghdr
结构,则从套接字本身获取目标地址和端口,并标记套接字为“已连接”。
两种情况下,都设置 daddr
和 dport
为目标地址和端口。
接下来,获取并存储套接字上设置的源地址、设备索引和时间戳选项(如SOCK_TIMESTAMPING_TX_HARDWARE
、SOCK_TIMESTAMPING_TX_SOFTWARE
、SOCK_WIFI_STATUS
):
ipc.addr = inet->inet_saddr;ipc.oif = sk->sk_bound_dev_if;sock_tx_timestamp(sk, &ipc.tx_flags);
sendmsg
发送辅助消息除了发送或接收数据包之外,sendmsg
和 recvmsg
系统调用还允许用户设置或请求辅助数据。 用户程序可以创建一个嵌入了请求的 struct msghdr
,来使用这些辅助数据。许多辅助数据类型都记录在 IP 手册页 中。
辅助数据的一个常见例子是 IP_PKTINFO
。 在 sendmsg
的情况下,此数据类型允许程序设置 struct in_pktinfo
,以便发送数据时使用。 通过在结构 struct in_pktinfo
中填充字段,程序可以指定要在数据包上使用的源地址。 如果程序是侦听多个 IP 地址的服务器程序,这是一个有用的选项。 在这种情况下,服务器程序可能希望使用与客户端连接服务器的 IP 地址来回复客户端。IP_PKTINFO
恰好适合这种情况。
类似地,当用户程序向 sendmsg
传递数据时, IP_TTL
和 IP_TOS
辅助消息允许用户在每个数据包的级别设置 IP 数据包的 TTL 和 TOS 值。如果需要,也可以通过使用 setsockopt
设置 IP_TTL
和 IP_TOS
在套接字级别,生效套接字的所有传出数据包。 Linux 内核使用数组转换指定的 TOS 值为优先级。 优先级影响数据包从排队规则传输的方式和时间。 稍后会详细了解这意味着什么。
内核如何处理 sendmsg
在 UDP 套接字上的辅助消息:
if (msg->msg_controllen) { err = ip_cmsg_send(sock_net(sk), msg, &ipc, sk->sk_family == AF_INET6); if (err) return err; if (ipc.opt) free = 1; connected = 0;}
./net/ipv4/ip_sockglue. c 中的 ip_cmsg_send
负责辅助消息的内部解析。 请注意,只要提供任何辅助数据,都会标记该套接字为未连接。
接下来,sendmsg
检查用户是否指定了任何带有自定义 IP 选项的辅助消息。 如果设置了选项,则使用这些选项。 如果没有,则使用此套接字已在使用的选项:
if (!ipc.opt) { struct ip_options_rcu *inet_opt; rcu_read_lock(); inet_opt = rcu_dereference(inet->inet_opt); if (inet_opt) { memcpy(&opt_copy, inet_opt, sizeof(*inet_opt) + inet_opt->opt.optlen); ipc.opt = &opt_copy.opt; } rcu_read_unlock();}
接下来,该函数检查是否设置了源记录路由(SRR)IP 选项。 源记录路由有两种类型:宽松源记录路由和严格源记录路由。 如果设置了此选项,记录并存储第一跳地址为 faddr
,标记套接字为“未连接”。 faddr
将在后面用到:
ipc.addr = faddr = daddr;if (ipc.opt && ipc.opt->opt.srr) { if (!daddr) return -EINVAL; faddr = ipc.opt->opt.faddr; connected = 0;}
在处理 SRR 选项后,从用户辅助消息设置的值,或套接字当前使用的值中,获取 TOS IP 标志。 随后进行检查以确定:
setsockopt
)SO_DONTROUTE
,或sendto
或 sendmsg
时,是否已指定 MSG_DONTROUTE
标志,或is_strictroute
,代表需要严格源记录路由然后,置位 tos
的 0x1
(RTO_ONLINK
)位,且标记套接字为“未连接”:
tos = get_rttos(&ipc, inet);if (sock_flag(sk, SOCK_LOCALROUTE) || (msg->msg_flags & MSG_DONTROUTE) || (ipc.opt && ipc.opt->opt.is_strictroute)) { tos |= RTO_ONLINK; connected = 0;}
接下来,代码尝试处理组播。 这有点棘手,因为如前所述,用户可以发送辅助 IP_PKTINFO
消息来指定一个源地址或设备索引来发送数据包。
如果目标地址是组播地址:
除非用户发送 IP_PKTINFO
辅助消息覆盖设备索引。 我们来看一下:
if (ipv4_is_multicast(daddr)) { if (!ipc.oif) ipc.oif = inet->mc_index; if (!saddr) saddr = inet->mc_addr; connected = 0;} else if (!ipc.oif) ipc.oif = inet->uc_index;
如果目标地址不是组播地址,则会设置设备索引,除非用户使用 IP_PKTINFO
覆盖了该索引。
是时候探讨路由了!
UDP 层负责路由的代码从一个快速路径开始。如果套接字已连接,请尝试获取路由结构:
if (connected) rt = (struct rtable *)sk_dst_check(sk, 0);
如果套接字没有连接,或者虽然连接了,但路由助手 sk_dst_check
判定路由已淘汰,则代码进入慢速路径以生成路由结构。 首先调用 flowi4_init_output
来构造一个描述此 UDP 流的结构:
if (rt == NULL) { struct net *net = sock_net(sk); fl4 = &fl4_stack; flowi4_init_output(fl4, ipc.oif, sk->sk_mark, tos, RT_SCOPE_UNIVERSE, sk->sk_protocol, inet_sk_flowi_flags(sk)|FLOWI_FLAG_CAN_SLEEP, faddr, saddr, dport, inet->inet_sport);
一旦该流结构构造完成,套接字及其流结构就被传递到安全子系统,使得诸如 SELinux 或 SMACK 之类的系统可以在流结构上设置安全 id 值。 接下来,ip_route_output_flow
调用 IP 路由代码来生成此流的路由结构:
security_sk_classify_flow(sk, flowi4_to_flowi(fl4));rt = ip_route_output_flow(net, fl4, sk);
如果无法生成路由结构,并且错误为 ENETUNREACH
,则 OUTNOROUTES
统计计数器增加。
if (IS_ERR(rt)) { err = PTR_ERR(rt); rt = NULL; if (err == -ENETUNREACH) IP_INC_STATS(net, IPSTATS_MIB_OUTNOROUTES); goto out;}
保存上述统计计数器的文件的位置、其他计数器及其含义,将在下面的 UDP 监控章节中讨论。
接下来,如果路由用于广播,但是在套接字上没有设置 SOCK_BROADCAST
套接字选项,则代码终止。 如果套接字“已连接”(如本函数所述),则缓存路由结构到套接字:
err = -EACCES;if ((rt->rt_flags & RTCF_BROADCAST) && !sock_flag(sk, SOCK_BROADCAST)) goto out;if (connected) sk_dst_set(sk, dst_clone(&rt->dst));
MSG_CONFIRM
阻止 ARP 缓存失效在调用 send
、sendto
或 sendmsg
时,如果用户指定了 MSG_CONFIRM
标志,UDP 协议层将处理该标志:
if (msg->msg_flags&MSG_CONFIRM) goto do_confirm;back_from_confirm:
此标志指示系统确认 ARP 缓存条目仍然有效,并阻止其被垃圾回收。 dst_confirm
函数只是在目标缓存条目上设置一个标志,在查询邻居缓存并找到条目时再次检查该标志。我们稍后再看。 UDP 网络应用程序常使用此功能 ,以减少不必要的 ARP 流量。 do_confirm
标签位于此函数的末尾附近,但它很简单:
do_confirm: dst_confirm(&rt->dst); if (!(msg->msg_flags&MSG_PROBE) || len) goto back_from_confirm; err = 0; goto out;
这段代码确认缓存条目,如果不是探测消息,则跳回到 back_from_confirm
。
一旦 do_confirm
代码跳回到 back_from_confirm
(或者没有跳转 do_confirm
),代码会尝试处理 UDP cork 和 uncorked 的情况。
如果未请求 UDP corking,调用 ip_make_skb
,数据可以打包到 struct sk_buff
,并传递给 udp_send_skb
,以向下移动栈并更接近 IP 协议层。 请注意,前面调用 ip_route_output_flow
生成的路由结构也会传入。 它将被关联到 skb,并稍后在 IP 协议层中使用。
/* Lockless fast path for the non-corking case. */if (!corkreq) { skb = ip_make_skb(sk, fl4, getfrag, msg->msg_iov, ulen, sizeof(struct udphdr), &ipc, &rt, msg->msg_flags); err = PTR_ERR(skb); if (!IS_ERR_OR_NULL(skb)) err = udp_send_skb(skb, fl4); goto out;}
ip_make_skb
函数尝试构建一个 skb,其考虑了各种因素,例如:
大多数网络设备驱动程序不支持 UFO,因为网络硬件本身不支持此功能。 让我们看一下这段代码,记住 corking 是禁用的。 接下来我们查看启用 corking 的路径。
ip_make_skb
ip_make_skb
函数可以在 ./net/ipv4/ip_output.c 中找到。 这个函数有点棘手。 ip_make_skb
依赖底层代码(译者释:__ip_make_skb
)构建 skb,它需要传入一个 corking 结构和 skb 排队的队列。 在套接字没有 corked 的情况下,传入一个伪 corking 结构和空队列。
让我们来看看伪 corking 结构和队列是如何构造的:
struct sk_buff *ip_make_skb(struct sock *sk, /* more args */){ struct inet_cork cork; struct sk_buff_head queue; int err; if (flags & MSG_PROBE) return NULL; __skb_queue_head_init(&queue); cork.flags = 0; cork.addr = 0; cork.opt = NULL; err = ip_setup_cork(sk, &cork, /* more args */); if (err) return ERR_PTR(err);
如上所述,corking 结构(cork
)和队列(queue
)都在栈上分配的;当 ip_make_skb
完成时,两者都不再需要。 调用 ip_setup_cork
来构建伪 corking 结构,它分配内存、并初始化结构。 接下来,调用 __ip_append_data
,传入队列和 corking 结构:
err = __ip_append_data(sk, fl4, &queue, &cork, ¤t->task_frag, getfrag, from, length, transhdrlen, flags);
稍后我们将看到这个函数是如何工作的,因为它在套接字是否被 corked 的情况下都会使用。 现在,我们只需要知道 __ip_append_data
会创建一个 skb,向其追加数据,并添加该 skb 到传入的队列中。 如果追加数据失败,则调用 __ip_flush_pending_frame
静默丢弃数据,并向上返回错误码:
if (err) { __ip_flush_pending_frames(sk, &queue, &cork); return ERR_PTR(err);}
最后,如果没有错误发生,__ip_make_skb
出队队列中的 skb,添加 IP 选项,并返回一个 skb,该 skb 已准备好传递给底层发送:
return __ip_make_skb(sk, fl4, &queue, &cork);
如果没有发生错误,则 skb 会交给 udp_send_skb
,它传递 skb 到网络栈的下一层,即 IP 协议栈:
err = PTR_ERR(skb);if (!IS_ERR_OR_NULL(skb)) err = udp_send_skb(skb, fl4);goto out;
如果出现错误,将在稍后计数。 有关详细信息,请参阅 UDP corking 的“错误统计”部分。
如果正在使用 UDP corking,但没有预先存在的 corked 数据,则慢速路径开始:
你可以在下一段代码中看到这一点,udp_sendmsg
继续向下:
lock_sock(sk); if (unlikely(up->pending)) { /* The socket is already corked while preparing it. */ /* ... which is an evident application bug. --ANK */ release_sock(sk); LIMIT_NETDEBUG(KERN_DEBUG pr_fmt("cork app bug 2\n")); err = -EINVAL; goto out; } /* * Now cork the socket to pend data. */ fl4 = &inet->cork.fl.u.ip4; fl4->daddr = daddr; fl4->saddr = saddr; fl4->fl4_dport = dport; fl4->fl4_sport = inet->inet_sport; up->pending = AF_INET;do_append_data: up->len += ulen; err = ip_append_data(sk, fl4, getfrag, msg->msg_iov, ulen, sizeof(struct udphdr), &ipc, &rt, corkreq ? msg->msg_flags|MSG_MORE : msg->msg_flags);
ip_append_data
ip_append_data
是一个小的包装函数,它在调用 __ip__append_data
之前做两件主要事情:
MSG_PROBE
标志。 此标志表示用户不想真正发送数据。 应探测路径(例如,以确定 PMTU)。ip_setup_cork
来设置 corking。处理完上述条件后,就会调用 __ip_append_data
函数,该函数包含大量逻辑以处理数据为数据包。
__ip_append_data
如果套接字被 corked,则从 ip_append_data
调用该函数;如果套接字未被 corked ,则从 ip_make_skb
调用该函数。 在这两种情况下,该函数要么分配一个新的缓冲区来存储传入的数据,要么追加数据到现有数据中。
这种工作方式以套接字的发送队列为中心。 等待发送的现有数据(例如,如果套接字被 corked)在队列中有一个条目,可以在其中追加其他数据。
这个函数很复杂;它执行多轮计算,以确定如何构建传递给底层网络层的 skb,并且详细探讨缓冲器分配过程对于理解如何传输网络数据并非绝对必要。
该函数的重点包括:
NETIF_F_UFO
。NETIF_F_SG
功能标志进行通告。 该功能的可用性表明,网络卡能够处理数据分散在一组缓冲区中的数据包;内核不需要花费时间合并多个缓冲区为单个缓冲区。期望的是结果避免额外的复制,大多数网卡都支持该功能。sock_wmalloc
跟踪发送队列的大小。 当分配一个新的 skb 时,skb 的大小会被计入拥有它的套接字,并且套接字的发送队列的分配字节会增加。 如果发送队列中没有足够的空间,则不分配 skb,并返回并跟踪错误。 我们将在下面的调优部分看到如何设置套接字发送队列大小。此函数执行成功后,将返回 0
。此时传输的数据已组装成适合网络设备的 skb,等待在发送队列上。
在 uncorked 的情况下,持有 skb 的队列传递给上述的 __ip_make_skb
,在那里它出队并准备经由 udp_send_skb
发送到更低层。
在 corked 的情况下,向上传递 __ip_append_data
的返回值。 数据停留在发送队列中,直到udp_sendmsg
确定是时候调用 udp_push_pending_frames
确认 skb 并调用 udp_send_skb
。
现在,udp_sendmsg
继续检查 ___ip_append_skb
的返回值 (下面的 err
):
if (err) udp_flush_pending_frames(sk);else if (!corkreq) err = udp_push_pending_frames(sk);else if (unlikely(skb_queue_empty(&sk->sk_write_queue))) up->pending = 0;release_sock(sk);
让我们来看看每个分支:
err
非零),则调用 udp_flush_pending_frames
,从而取消阻塞并从套接字的发送队列中删除所有数据。MSG_MORE
,则称为 udp_push_pending_frames
,它尝试传递数据到较低的网络层。如果 append 操作成功完成,并且还有更多的数据要 cork,则代码继续清理并返回所追加的数据的长度:
ip_rt_put(rt);if (free) kfree(ipc.opt);if (!err) return len;
这就是内核处理 corked 的 UDP 套接字的方式。
如果:
udp_send_skb
报告错误,或ip_append_data
无法追加数据到 corked 的 UDP 套接字,或udp_push_pending_frames
返回从 udp_send_skb
收到的错误只有当收到的错误是 ENOBUFS
(没有可用的内核内存)或套接字设置了 SOCK_NOSPACE
(发送队列已满)时,SNDBUFERRORS
统计信息才会增加:
/* * ENOBUFS = no kernel mem, SOCK_NOSPACE = no sndbuf space. Reporting * ENOBUFS might not be good (it's not tunable per se), but otherwise * we don't have a good statistic (IpOutDiscards but it can be too many * things). We could add another new stat but at least for now that * seems like overkill. */if (err == -ENOBUFS || test_bit(SOCK_NOSPACE, &sk->sk_socket->flags)) { UDP_INC_STATS_USER(sock_net(sk), UDP_MIB_SNDBUFERRORS, is_udplite);}return err;
我们将在下面的监控部分看到如何读取这些计数。
udp_send_skb
udp_sendmsg
调用 udp_send_skb
函数 最终下推 skb 到网络栈的下一层,在本例中是 IP 协议层。 该函数做了几件重要的事情:
ip_send_skb
发送 skb 到 IP 协议层。我们来看看。 首先,创建 UDP 报头:
static int udp_send_skb(struct sk_buff *skb, struct flowi4 *fl4){/* useful variables ... */ /* * Create a UDP header */ uh = udp_hdr(skb); uh->source = inet->inet_sport; uh->dest = fl4->fl4_dport; uh->len = htons(len); uh->check = 0;
接下来,处理校验和。 有几种情况:
setsockopt
设置 SO_NO_CHECK
),将如此标记 skb。udp4_hwcsum
来设置。 请注意,如果数据包被分段,内核将在软件中生成校验和。 您可以在 udp4_hwcsum
的源代码中看到这一点。udp_csum
生成软件校验和。if (is_udplite) /* UDP-Lite */ csum = udplite_csum(skb);else if (sk->sk_no_check == UDP_CSUM_NOXMIT) { /* UDP csum disabled */ skb->ip_summed = CHECKSUM_NONE; goto send;} else if (skb->ip_summed == CHECKSUM_PARTIAL) { /* UDP hardware csum */ udp4_hwcsum(skb, fl4->saddr, fl4->daddr); goto send;} else csum = udp_csum(skb);
接下来,添加 psuedo 报头:
uh->check = csum_tcpudp_magic(fl4->saddr, fl4->daddr, len, sk->sk_protocol, csum);if (uh->check == 0) uh->check = CSUM_MANGLED_0;
如果校验和为 0,则根据 RFC 768 设置其等效的补码值为校验和。最终,skb 被传递到 IP 协议栈,增加统计信息:
send: err = ip_send_skb(sock_net(sk), skb); if (err) { if (err == -ENOBUFS && !inet->recverr) { UDP_INC_STATS_USER(sock_net(sk), UDP_MIB_SNDBUFERRORS, is_udplite); err = 0; } } else UDP_INC_STATS_USER(sock_net(sk), UDP_MIB_OUTDATAGRAMS, is_udplite); return err;
如果 ip_send_skb
执行成功,则增加 OUTDATAGRAMS
统计信息。 如果 IP 协议层报告错误,则增加 SNDBUFERRORS
,但仅当错误为 ENOBUFS
(内核内存不足)且未启用错误队列时,才增加。
在讨论 IP 协议层之前,让我们先看看如何在 Linux 内核中监控和调优 UDP 协议层。
获取 UDP 协议统计信息的两个非常有用的文件是:
/proc/net/snmp
/proc/net/udp
/proc/net/snmp
读取 /proc/net/snmp
监控详细的 UDP 协议统计信息。
$ cat /proc/net/snmp | grep Udp\:Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrorsUdp: 16314 0 0 17161 0 0
为了准确地理解这些统计信息在哪里增加,您需要仔细阅读内核源代码。 在一些情况下,一些错误会计入多个统计量中。
InDatagrams
:当用户程序使用 recvmsg
读取数据报时增加。 当 UDP 数据包被封装并发回处理时,也会增加。NoPorts
:当 UDP 数据包到达目的地为没有程序侦听的端口时增加。InErrors
:在以下几种情况下增加:接收队列中没有内存,当看到错误的校验和时,sk_add_backlog
无法添加数据报。OutDatagrams
:当 UDP 数据包无错误地传递到要发送的 IP 协议层时增加。RcvbufErrors
:当 sock_queue_rcv_skb
报告没有可用内存时增加;如果 sk->sk_rmem_alloc
大于等于 sk->sk_rcvbuf
就会发生这种情况。SndbufErrors
:如果 IP 协议层在尝试发送数据包时报告错误,并且没有设置错误队列,则会增加。 如果没有可用的发送队列空间或内核内存,也会增加。InCsumErrors
:检测到 UDP 校验和失败时增加。 请注意,在我能找到的所有情况下,InCsumErrors
与 InErrors
会同时增加。 因此,InErrors
-InCsumErros
应当得出接收端的内存相关错误的计数。请注意,UDP 协议层发现的一些错误会报告到其他协议层的统计信息文件。 举个例子:路由错误。 udp_sendmsg
发现的路由错误将增加 IP 协议层的 OutNoRoutes
统计信息。
/proc/net/udp
读取 /proc/net/udp
监控 UDP 套接字统计信息
$ cat /proc/net/udp sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops 515: 00000000:B346 00000000:0000 07 00000000:00000000 00:00000000 00000000 104 0 7518 2 0000000000000000 0 558: 00000000:0371 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7408 2 0000000000000000 0 588: 0100007F:038F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7511 2 0000000000000000 0 769: 00000000:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7673 2 0000000000000000 0 812: 00000000:006F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7407 2 0000000000000000 0
第一行描述后续行中的每个字段:
sl
:套接字的内核哈希槽local_address
:套接字的十六进制本地地址和端口号,以 :
分隔。rem_address
:套接字的十六进制远程地址和端口号,以 :
分隔。st
:套接字的状态。 奇怪的是,UDP 协议层似乎使用了一些 TCP 套接字状态。 在上面的例子中,7
是 TCP_CLOSE
。tx_queue
:内核中为传出 UDP 数据报分配的内存量。rx_queue
:内核中为传入 UDP 数据报分配的内存量。tr
,tm->when
,retrnsmt
:UDP 协议层未使用这些字段。uid
:创建此套接字的用户的有效用户 ID。timeout
:UDP 协议层未使用。inode
:与此套接字对应的 inode 编号。 您可以使用它来帮助您确定哪个用户进程打开了此套接字。 检查 /proc/[pid]/fd
,它将包含到 socket:[inode]
的符号链接。ref
:套接字的当前引用计数。pointer
:内核中 struct sock
的内存地址。drops
:与此套接字关联的数据报丢弃数。 请注意,这不包括任何与发送数据报有关的丢弃(在 corked 的 UDP 套接字上,或其他);在本博客考察的内核版本中,只在接收路径中增加。可以在 net/ipv4/udp.c
中找到输出此内容的代码。
发送队列(也称为写入队列)的最大大小可以设置 net.core.wmem_max
sysctl 来调整
设置 sysctl
增加最大发送缓冲区大小。
$ sudo sysctl -w net.core.wmem_max=8388608
sk->sk_write_queue
从 net.core.wmem_default
值开始,也可以设置 sysctl 来调整,如下所示:
设置 sysctl
来调整默认的初始发送缓冲区大小 。
$ sudo sysctl -w net.core.wmem_default=8388608
您还可以从应用程序调用 setsockopt
并传递 SO_SNDBUF
来设置 sk->sk_write_queue
大小 。 您可以使用 setsockopt
设置的最大值是 net.core.wmem_max
。
但是,当运行应用程序的用户具有 CAP_NET_ADMIN
权限时,可以调用 setsockopt
并传递 SO_SNDBUFFORCE
来覆盖 net.core.wmem_max
限制。
每次调用 ip_append_data
分配 skb 时,sk->sk_wmem_alloc
都会增加。 正如我们将看到的,UDP 数据报传输很快,通常不会在发送队列中花费太多时间。
UDP 协议层简单地调用 ip_send_skb
传递 skbs 给 IP 协议,因此让我们从那开始,并掌握 IP 协议层!
ip_send_skb
ip_send_skb
函数位于 ./net/ipv4/ip_output.c 中,非常短。 它只是向下调用 ip_local_out
,如果 ip_local_out
返回某种错误,它就会增加错误统计信息。 我们来看一下:
int ip_send_skb(struct net *net, struct sk_buff *skb){ int err; err = ip_local_out(skb); if (err) { if (err > 0) err = net_xmit_errno(err); if (err) IP_INC_STATS(net, IPSTATS_MIB_OUTDISCARDS); } return err;}
如上所述,调用 ip_local_out
,然后处理返回值。 调用 net_xmit_errno
“翻译” 来自底层的错误为 IP 和 UDP 协议层可以理解的错误。 如果发生错误,将增加 IP 协议统计信息 “OutDiscards” 。 稍后我们将看到获得此统计信息要读取哪些文件。 现在,让我们继续探索,看看 ip_local_out
会把我们带到哪里。
ip_local_out
和 __ip_local_out
幸运的是,ip_local_out
和 __ip_local_out
都很简单。ip_local_out
只是向下调用 __ip_local_out
,并根据返回值调用路由层发送数据包:
int ip_local_out(struct sk_buff *skb){ int err; err = __ip_local_out(skb); if (likely(err == 1)) err = dst_output(skb); return err;}
可以从 __ip_local_out
的源代码中看到,该函数首先做了两件重要的事情:
ip_send_check
计算要写入 IP 数据包报头的校验和。 ip_send_check
函数调用 ip_fast_csum
来计算校验和。 在 x86 和 x86_64 体系结构上,此功能以汇编实现。 你可以在这里阅读 64 位的实现,在这里阅读 32 位的实现。接下来,IP 协议层调用 nf_hook
向下调用 netfilter。传回 nf_hook
函数的返回值给 ip_local_out
。 如果 nf_hook
返回 1
,表明允许数据包通过,调用者应该自己传递它。 正如我们在上面看到的,实际正是如此:ip_local_out
检查返回值 1
,并调用 dst_output
传递数据包。 让我们来看看 __ip_local_out
的代码:
int __ip_local_out(struct sk_buff *skb){ struct iphdr *iph = ip_hdr(skb); iph->tot_len = htons(skb->len); ip_send_check(iph); return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT, skb, NULL, skb_dst(skb)->dev, dst_output);}
nf_hook
简洁起见,我决定跳过对 netfilter、iptables 和 conntrack 的深入研究。 你可以从 这里 和 这里 开始深入了解 netfilter 的源代码。
简版:nf_hook
是一个包装器,它调用 nf_hook_thresh
,首先检查指定的协议族和钩子类型(在本例中分别为 NFPROTO_IPV4
和 NF_INET_LOCAL_OUT
)是否安装了过滤器,并试图返回执行流程到 IP 协议层,以避免深入 netfilter 和在其下面的钩子,如 iptables 和 conntrack。
请记住:如果你有很多或非常复杂的 netfilter 或 iptables 规则,这些规则将在启动原始 sendmsg
调用的用户进程的 CPU 上下文中执行。 如果您设置了 CPU pinning 以限制此进程的执行到特定的 CPU(或一组 CPU),请注意 CPU 将花费系统时间处理出站 iptables 规则。 根据系统的工作负载,如果您在这里测量性能回归,您可能需要小心地固定进程到 CPU 或降低规则集的复杂性。
为了便于讨论,我们假设 nf_hook
返回 1
表示调用方(在本例中是 IP 协议层)应该自己传递数据包。
在 Linux 内核中,dst
代码实现了协议无关的目标缓存。 为了理解如何设置 dst
条目以继续发送 UDP 数据报,我们需要简要地探讨一下 dst
条目和路由是如何生成的。 目标缓存、路由和邻居子系统都可以单独进行极其详细的探讨。 出于我们的目的,我们可以快速查看一下这一切是如何结合在一起的。
我们上面看到的代码调用了 dst_output(skb)
。 这个函数只是查找 skb 附加的 dst
条目 skb
并调用 output 函数。 我们来看一下:
/* Output packet to network from transport. */static inline int dst_output(struct sk_buff *skb){ return skb_dst(skb)->output(skb);}
看起来很简单,但 output 函数起初是如何被关联到 dst
条目的呢?
重要的是要了解,有许多不同的方式添加目标缓存条目。 到目前为止,我们在代码路径中看到的一种方式是从 udp_sendmsg
调用 ip_route_output_flow
。 ip_route_output_flow
函数调用 __ip_route_output_key
,后者调用 __mkroute_output
。 __mkroute_output
函数创建路由和目标缓存条目。 当它执行时,它会确定适合于此目标的输出函数。 大多数时候,这个函数是 ip_output
。
ip_output
因此,dst_output
执行 output
函数,在 UDP IPv4 情况下为 ip_output
。 ip_output
函数很简单:
int ip_output(struct sk_buff *skb){ struct net_device *dev = skb_dst(skb)->dev; IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUT, skb->len); skb->dev = dev; skb->protocol = htons(ETH_P_IP); return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev, ip_finish_output, !(IPCB(skb)->flags & IPSKB_REROUTED));}
首先,更新统计计数器 IPSTATS_MIB_OUT
。 IP_UPD_PO_STATS
宏增加字节数和数据包数。 我们将在后面的部分中看到如何获得 IP 协议层统计信息以及它们各自的含义。 接下来,设置传输此 skb 的设备、协议。
最后,调用 NF_HOOK_COND
传递控制权给 netfilter。 查看 NF_HOOK_COND
的函数原型有助于更清楚地解释它的工作原理。 来源为 ./include/linux/netfilter.h:
static inline intNF_HOOK_COND(uint8_t pf, unsigned int hook, struct sk_buff *skb, struct net_device *in, struct net_device *out, int (*okfn)(struct sk_buff *), bool cond)
NF_HOOK_COND
检查传入的条件。 在此情况下,条件是 !(IPCB(skb)->flags & IPSKB_REROUTED
。 如果条件为真,那么传递 skb
给 netfilter。 如果 netfilter 允许数据包通过,则调用 okfn
。 此情况下,okfn
是 ip_finish_output
。
ip_finish_output
ip_finish_output
函数也很简洁明了。 我们来看一下:
static int ip_finish_output(struct sk_buff *skb){#if defined(CONFIG_NETFILTER) && defined(CONFIG_XFRM) /* Policy lookup after SNAT yielded a new policy */ if (skb_dst(skb)->xfrm != NULL) { IPCB(skb)->flags |= IPSKB_REROUTED; return dst_output(skb); }#endif 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);}
如果在此内核中启用了 netfilter 和数据包转换,会更新 skb
的标志,并通过 dst_output
将其发送回。 两种比较常见的情况是:
ip_fragment
以在传输之前对数据包进行分段。ip_finish_output2
。在继续内核学习之前,让我们稍微绕个圈子来讨论一下路径 MTU 发现。
Linux 提供了一个我前面避免提到的特性:路径 MTU 发现。 此功能允许内核自动确定特定路由的最大 MTU。 确定此值并发送小于或等于路由 MTU 的数据包意味着可以避免 IP 分段。 这是首选设置,因为数据包分段会消耗系统资源,而且似乎很容易避免:简单地发送足够小的数据包,就不需要分段。
调用 setsockopt
,您可以在应用程序中使用 SOL_IP
级别和 IP_MTU_DISCOVER
optname 调整每个套接字的路径 MTU 发现设置。optval 可以是 IP 协议手册页中描述的几个值之一。 您可能希望设置的值为:IP_PMTUDISC_DO
表示“始终执行路径 MTU 发现”。 更高级的网络应用程序或诊断工具可以选择自己实现 RFC 4821 ,以在应用程序启动时确定特定路由的 PMTU。 在这种情况下,您可以使用 IP_PMTUDISC_PROBE
选项,该选项告诉内核设置“Don’t Fragment”位,允许您发送大于 PMTU 的数据。
调用 getsockopt
,您的应用程序可以使用 SOL_IP
和 IP_MTU
optname 来检索 PMTU。 您可以使用它来帮助指导应用程序尝试在传输之前构造 UDP 数据报的大小。
如果已启用 PTMU 发现,则任何发送大于 PMTU 的 UDP 数据的尝试都将导致应用程序收到错误码 EMSGSIZE
。 然后,应用程序可以使用更少的数据重试。
强烈建议启用 PTMU 发现,因此我将避免详细描述 IP 分段代码路径。 当查看 IP 协议层统计信息时,我将解释所有统计信息,包括与分段相关的统计信息。 其中许多在 ip_fragment
。 无论是否分段,都调用了 ip_finish_output2
,所以让我们继续。
ip_finish_output2
ip_finish_output2
在 IP 分段之后被调用,并且也直接从 ip_finish_output
调用。 在向下传递数据包到邻居缓存之前,此函数增加各种统计计数器。 让我们看看它是如何工作的:
static inline int ip_finish_output2(struct sk_buff *skb){/* variable declarations */ if (rt->rt_type == RTN_MULTICAST) { IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUTMCAST, skb->len); } else if (rt->rt_type == RTN_BROADCAST) IP_UPD_PO_STATS(dev_net(dev), IPSTATS_MIB_OUTBCAST, skb->len); /* Be paranoid, rather than too clever. */ if (unlikely(skb_headroom(skb) < hh_len && dev->header_ops)) { struct sk_buff *skb2; skb2 = skb_realloc_headroom(skb, LL_RESERVED_SPACE(dev)); if (skb2 == NULL) { kfree_skb(skb); return -ENOMEM; } if (skb->sk) skb_set_owner_w(skb2, skb->sk); consume_skb(skb); skb = skb2; }
如果与此数据包相关联的路由结构是组播类型,使用IP_UPD_PO_STATS
宏来增加 OutMcastPkts
和 OutMcastOctets
计数器。 否则,如果路由类型为广播,则增加 OutBcastPkts
和 OutBcastOctets
计数器。
接下来,执行检查以确保 skb 结构具有足够的空间添加任何需要的链路层报头。 如果没有,则调用 skb_realloc_headroom
来分配额外的空间,并且新 skb 的成本将计入相关套接字。
rcu_read_lock_bh();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);
继续,我们可以看到,下一跳是查询路由层,然后查找邻居缓存得到的。 如果找不到邻居,则调用 __neigh_create
创建一个。 例如,数据第一次发送到另一台主机时可能出现此情况。 请注意,此函数是调用 arp_tbl
(在 ./net/ipv4/arp.c 中定义),在 ARP 表中创建邻居条目。 其他系统(如 IPv6 或 DECnet)维护自己的 ARP 表,并传递不同的结构给 __neigh_create
。 本文并不旨在全面介绍邻居缓存,但如果必须创建邻居缓存,那么创建可能会导致缓存增长。 这篇文章将在下面的章节中介绍更多关于邻居缓存的细节。 无论如何,邻居缓存导出自己的统计信息,以便可以测量缓存增长。 有关详细信息,请参阅下面的监控部分。
if (!IS_ERR(neigh)) { int res = dst_neigh_output(dst, neigh, skb); rcu_read_unlock_bh(); return res; } rcu_read_unlock_bh(); net_dbg_ratelimited("%s: No header cache and no neighbour!\n", __func__); kfree_skb(skb); return -EINVAL;}
最后,如果没有返回错误,则调用 dst_neigh_output
沿着输出的旅程传递 skb。 否则,释放 skb 并返回 EINVAL。 此处的错误将产生连锁反应,并增加 ip_send_skb
中的 OutDiscards
。 让我们继续探索 dst_neigh_output
,并继续接近 Linux 内核的网络设备子系统。
dst_neigh_output
dst_neigh_output
函数为我们做了两件重要的事情。 首先,回想一下在这篇博客文章的前面,我们看到如果用户通过辅助消息指定 MSG_CONFIRM
给 sendmsg
函数,则会翻转一个标志,指示远程主机的目标缓存条目仍然有效,不应被垃圾回收。 该检查在这里发生,设置邻居的 confirmed
字段为当前的 jiffies 计数。
static inline int dst_neigh_output(struct dst_entry *dst, struct neighbour *n, struct sk_buff *skb){ const struct hh_cache *hh; if (dst->pending_confirm) { unsigned long now = jiffies; dst->pending_confirm = 0; /* avoid dirtying neighbour */ if (n->confirmed != now) n->confirmed = now; }
其次,检查邻居的状态,并调用适当的输出函数。 让我们来看看以下条件句,试着理解是怎么回事:
hh = &n->hh; if ((n->nud_state & NUD_CONNECTED) && hh->hh_len) return neigh_hh_output(hh, skb); else return n->output(n, skb);}
如果邻居被认为是 NUD_CONNECTED
,则意味着它是以下情况的一种或多种:
NUD_PERMANENT
:静态路由。NUD_NOARP
:不需要 ARP 请求(例如,目的地是组播或广播地址,或环回设备)。NUD_REACHABLE
:邻居是“可达的”。只要 ARP 请求 成功处理,目的地就会被标记为可达。且 “硬件头”(hh
)已缓存(因为之前发送过数据并已生成它),则调用 neigh_hh_output
。否则,调用 output
函数。两条代码路径都以 dev_queue_xmit
结束,它传递 skb 到 Linux 网络设备子系统,在到达设备驱动程序层之前会进行更多处理。让我们跟随 neigh_hh_output
和 n->output
代码路径,直至 dev_queue_xmit
。
neigh_hh_output
如果目标是 NUD_CONNECTED
,并且硬件头已缓存,则调用 neigh_hh_output
,它在移交skb 给 dev_queue_xmit
之前执行一小段处理逻辑。 让我们从 ./include/net/neighbor.h 来看看:
static inline int neigh_hh_output(const struct hh_cache *hh, struct sk_buff *skb){ unsigned int seq; int hh_len; do { seq = read_seqbegin(&hh->hh_lock); hh_len = hh->hh_len; if (likely(hh_len <= HH_DATA_MOD)) { /* this is inlined by gcc */ memcpy(skb->data - HH_DATA_MOD, hh->hh_data, HH_DATA_MOD); } else { int hh_alen = HH_DATA_ALIGN(hh_len); memcpy(skb->data - hh_alen, hh->hh_data, hh_alen); } } while (read_seqretry(&hh->hh_lock, seq)); skb_push(skb, hh_len); return dev_queue_xmit(skb);}
这个函数有点难以理解,部分原因是同步读/写已缓存硬件头的锁定原语。 这段代码使用了一种叫做 seqlock 的东西。 你可以把上面的 do { } while()
循环想象成一种简单的重试机制,它将尝试执行循环中的操作,直到成功执行为止。
循环本身试图确定在复制之前是否需要对齐硬件头部的长度。 这是必需的,因为某些硬件报头(如 IEEE 802.11 报头)大于 HH_DATA_MOD
(16 字节)。
一旦数据被复制到 skb,并且 skb_push
更新了 skb 的内部指针跟踪数据,skb 就会传递给 dev_queue_xmit
进入 Linux 网络设备子系统。
n->output
如果目标不是 NUD_CONNECTED
或硬件头尚未缓存,则代码沿着 n->output
路径继续。 邻居结构的输出函数指针关联了什么 output
? 嗯,那要看情况了。 为了理解这是如何设置的,我们需要了解更多关于邻居缓存的工作原理。
一个 struct neighbour
包含几个重要的字段。 上面看到的 nud_state
字段,output
函数和 ops
结构。 回想一下之前看到的,如果在缓存中没有找到现有的条目,则从 ip_finish_output2
调用 __neigh_create
。 当调用 __neigh_creaet
时,邻居被分配,其 output
函数初始设置为 neigh_blackhole
。 随着 __neigh_create
代码执行,它根据邻居的状态调整 output
的值以指向适当的 output
函数。
例如,当代码确定要连接的邻居时,neigh_connect
设置 output
指针为 neigh->ops->connected_output
。 或者,在代码怀疑邻居可能关闭时(例如,如果自发送探测以来已经超过/proc/sys/net/ipv4/neigh/default/delay_first_probe_time
秒),neigh_suspect
设置 output
指针为 neigh->ops->output
。
换句话说:neigh->output
设置为 neigh->ops_connected_output
还是 neigh->ops->output
, 取决于邻居的状态。 neigh->ops
从何而来?
在分配邻居之后,arp_constructor
(来自 ./net/ipv4/arp.c)被调用来设置 struct neighbour
的一些字段。 特别地,此函数检查与邻居相关联的设备,并且如果该设备暴露包含cache
(以太网设备这样做)函数的 header_ops
结构 ,则 neigh->ops
被设置为 ./net/ipv4/arp. c 中定义的以下结构:
static const struct neigh_ops arp_hh_ops = { .family = AF_INET, .solicit = arp_solicit, .error_report = arp_error_report, .output = neigh_resolve_output, .connected_output = neigh_resolve_output,};
因此,无论邻居缓存代码是否视邻居为 “已连接”或“可疑”,都将关联 neigh_resolve_output
函数到 neigh->output
,并且在调用 n->output
时被调用。
neigh_resolve_output
此函数的目的是尝试解析未连接的邻居,或已连接但没有缓存硬件头的邻居。 让我们来看看这个函数是如何工作的:
/* Slow and careful. */int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb){ struct dst_entry *dst = skb_dst(skb); int rc = 0; if (!dst) goto discard; if (!neigh_event_send(neigh, skb)) { int err; struct net_device *dev = neigh->dev; unsigned int seq;
代码首先执行一些基本检查,然后继续调用 neigh_event_send
。 neigh_event_send
函数是__neigh_event_send
的简单包装。__neigh_event_send
实际完成解析邻居的繁重工作。 您可以在 ./net/core/neighbor.c 中阅读 __neigh_event_send
的源代码,但从代码中可以看出,用户最感兴趣的有三点:
/proc/sys/net/ipv4/neigh/default/app_solicit
/proc/sys/net/ipv4/neigh/default/mcast_solicit
中设置的值允许发送探测,则 NUD_NONE
状态(分配时的默认状态)的邻居将立即发送 ARP 请求(如果不允许,则标记状态为 NUD_FAILED
)。 邻居状态被更新并设置为 NUD_INCOMPLETE
。NUD_STALE
的邻居为 NUD_DELAYED
,并设置一个计时器以稍后探测它们(稍后:当前时间 +/proc/sys/net/ipv4/neigh/default/delay_first_probe_time
秒)。NUD_INCOMPLETE
的任何邻居 (包括上面第一点),以确保未解析邻居的排队数据包数量小于等于 /proc/sys/net/ipv4/neigh/default/unres_qlen
。 如果有更多的数据包,则将数据包出队并丢弃,直到长度低于等于 proc 中的值。针对此类情况,邻居缓存统计中的统计计数器都将增加。如果需要立刻发送 ARP 探测,它就会发送。__neigh_event_send
将返回 0
,指示邻居被视为“已连接”或“已延迟”的,否则返回 1
。 返回值 0
允许 neigh_resolve_output
函数继续执行:
if (dev->header_ops->cache && !neigh->hh.hh_len) neigh_hh_init(neigh, dst);
如果邻居关联的设备的协议实现(在此例子中是以太网)支持缓存硬件报头,并且它当前没有被缓存,则调用 neigh_hh_init
缓存它。
do { __skb_pull(skb, skb_network_offset(skb)); seq = read_seqbegin(&neigh->ha_lock); err = dev_hard_header(skb, dev, ntohs(skb->protocol), neigh->ha, NULL, skb->len);} while (read_seqretry(&neigh->ha_lock, seq));
接下来,使用 seqlock 同步访问邻居结构的硬件地址,当尝试为 skb 创建以太网报头时,dev_hard_header
将读取该地址。 一旦 seqlock 允许继续执行,就会进行错误检查:
if (err >= 0) rc = dev_queue_xmit(skb); else goto out_kfree_skb;}
如果以太网头被写入而没有返回错误,则 skb 被传递到 dev_queue_xmit
,以通过 Linux 网络设备子系统进行传输。 如果有错误,goto
将丢弃 skb,设置返回代码并返回错误:
out: return rc;discard: neigh_dbg(1, "%s: dst=%p neigh=%p\n", __func__, dst, neigh);out_kfree_skb: rc = -EINVAL; kfree_skb(skb); goto out;}EXPORT_SYMBOL(neigh_resolve_output);
在进入 Linux 网络设备子系统前,让我们看一下一些监控和调优 IP 协议层的文件。
/proc/net/snmp
读取 /proc/net/snmp
监控详细的 IP 协议统计信息。
$ cat /proc/net/snmpIp: Forwarding DefaultTTL InReceives InHdrErrors InAddrErrors ForwDatagrams InUnknownProtos InDiscards InDelivers OutRequests OutDiscards OutNoRoutes ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreatesIp: 1 64 25922988125 0 0 15771700 0 0 25898327616 22789396404 12987882 51 1 10129840 2196520 1 0 0 0...
此文件包含多个协议层的统计信息。 首先显示 IP 协议层。第一行包含空格分隔的名称,每个名称对应下一行中的相应值。
在 IP 协议层中,您会发现统计计数器正在增加。计数器引用 C 枚举类型。 /proc/net/snmp
所有有效的枚举值和它们对应的字段名称可以在 include/uapi/linux/snmp.h 中找到:
enum{ IPSTATS_MIB_NUM = 0,/* frequently written fields in fast path, kept in same cache line */ IPSTATS_MIB_INPKTS, /* InReceives */ IPSTATS_MIB_INOCTETS, /* InOctets */ IPSTATS_MIB_INDELIVERS, /* InDelivers */ IPSTATS_MIB_OUTFORWDATAGRAMS, /* OutForwDatagrams */ IPSTATS_MIB_OUTPKTS, /* OutRequests */ IPSTATS_MIB_OUTOCTETS, /* OutOctets */ /* ... */
一些有趣的统计数据:
OutRequests
:每次尝试发送 IP 数据包时增加。 看起来,每次是否成功,都会增加此值。OutDiscards
:每次丢弃 IP 数据包时增加。 如果数据追加到 skb(对于 corked 的套接字)失败,或者 IP 下面的层返回错误,就会发生这种情况。OutNoRoute
:在多个位置增加,例如在 UDP 协议层(udp_sendmsg
),如果无法为给定目标生成路由。 当应用程序在 UDP 套接字上调用 “connect” 但找不到路由时也会增加。FragOKs
:每个被分段的数据包增加一次。 例如,被分割成 3 个片段的数据包增加该计数器一次。FragCreates
:每个创建的片段增加一次。 例如,被分割成 3 个片段的数据包增加该计数器三次。FragFails
:如果尝试分段,但不允许分段,则增加(因为设置了 “Don’t Fragment” 位)。 如果输出片段失败,也会增加。其他统计数据记录在接收端博客文章中。
/proc/net/netstat
读取 /proc/net/netstat
监控扩展 IP 协议统计信息。
$ cat /proc/net/netstat | grep IpExtIpExt: InNoRoutes InTruncatedPkts InMcastPkts OutMcastPkts InBcastPkts OutBcastPkts InOctets OutOctets InMcastOctets OutMcastOctets InBcastOctets OutBcastOctets InCsumErrors InNoECTPkts InECT0Pktsu InCEPktsIpExt: 0 0 0 0 277959 0 14568040307695 32991309088496 0 0 58649349 0 0 0 0 0
格式类似于 /proc/net/snmp
,不同之处在于行的前缀是 IpExt
。
一些有趣的统计数据:
OutMcastPkts
:每次发送目的地为组播地址的数据包时增加。OutBcastPkts
:每次发送目的地为广播地址的数据包时增加。OutOctects
:输出的数据包字节数。OutMcastOctets
:输出的组播数据包字节数。OutBcastOctets
:输出的广播数据包字节数。其他统计数据记录在接收端博客文章中。
请注意,这些值都是在 IP 层的特定位置增加的。代码有时会移动,可能会出现双重计数错误或其他统计错误。如果这些统计数据对您很重要,强烈建议您阅读 IP 协议层源代码,了解您重要的指标何时增加(或不增加)。
在我们继续讨论 dev_queue_xmit
的数据包传输路径之前,让我们花一点时间来谈谈一些重要的概念,这些概念将出现在接下来的部分。
Linux 支持一种叫做流量控制的特性。 此功能允许系统管理员控制如何从计算机传输数据包。 本文不会深入讨论 Linux 流量控制的各方面的细节。这篇文档提供了对系统、其控制和特性的深入研究。 有几个概念值得一提,以使下面看到的代码更容易理解。
流量控制系统包含几种不同的排队系统,它们为控制流量提供不同的功能。单个排队系统通常称为 qdisc
,也称为排队规则。您可以将 qdisc 视为调度程序;qdisc 决定何时以及如何传输数据包。
在 Linux 上,每个接口都有一个与之关联的默认 qdisc。对于仅支持单个传输队列的网络硬件,使用默认 qdisc pfifo_fast
。支持多个传输队列的网络硬件使用默认 qdisc mq
。您可以运行 tc qdisc
来检查您的系统。
还需要注意的是,有些设备支持硬件流量控制,这可以让管理员将流量控制卸载到网络硬件上,从而节省系统上的 CPU 资源。
现在这些想法已经介绍过了,让我们从 ./net/core/dev.c 继续沿着 dev_queue_xmit
进行。
dev_queue_xmit
和 __dev_queue_xmit
dev_queue_xmit
是 __dev_queue_xmit
的一个简单包装:
int dev_queue_xmit(struct sk_buff *skb){ return __dev_queue_xmit(skb, NULL);}EXPORT_SYMBOL(dev_queue_xmit);
在此之后,__dev_queue_xmit
是完成繁重工作的地方。 让我们一步一步地看一下这段代码,继续:
static int __dev_queue_xmit(struct sk_buff *skb, void *accel_priv){ struct net_device *dev = skb->dev; struct netdev_queue *txq; struct Qdisc *q; int rc = -ENOMEM; skb_reset_mac_header(skb); /* Disable soft irqs for various locks below. Also * stops preemption for RCU. */ rcu_read_lock_bh(); skb_update_prio(skb);
上面的代码开始于:
skb_reset_mac_header
来准备要处理的 skb。 这将重置 skb 的内部指针,以便可以访问以太网报头。rcu_read_lock_bh
来准备读取 RCU 保护的数据结构。阅读更多关于安全使用 RCU 的信息。skb_update_prio
来设置 skb 的优先级。现在,我们将开始更复杂的数据传输部分 ;)
txq = netdev_pick_tx(dev, skb, accel_priv);
在这里,代码试图确定要使用哪个传输队列。 正如您将在本文后面看到的,一些网络设备公开了多个传输队列来传输数据。 让我们来详细看看这是如何工作的。
netdev_pick_tx
netdev_pick_tx
代码位于 ./net/core/flow_dissector.c 中。 我们来看一下:
struct netdev_queue *netdev_pick_tx(struct net_device *dev, struct sk_buff *skb, void *accel_priv){ int queue_index = 0; if (dev->real_num_tx_queues != 1) { const struct net_device_ops *ops = dev->netdev_ops; if (ops->ndo_select_queue) queue_index = ops->ndo_select_queue(dev, skb, accel_priv); else queue_index = __netdev_pick_tx(dev, skb); if (!accel_priv) queue_index = dev_cap_txqueue(dev, queue_index); } skb_set_queue_mapping(skb, queue_index); return netdev_get_tx_queue(dev, queue_index);}
正如您在上面看到的,如果网络设备只支持单个传输队列,则会跳过更复杂的代码,并返回单个传输队列。 在高端服务器上使用的大多数设备具有多个传输队列。 具有多个传输队列的设备有两种情况:
ndo_select_queue
,它可以以硬件或功能特定的方式更智能地选择传输队列,或者ndo_select_queue
,所以内核应该自己选择设备。截止 3.13 内核,实现 ndo_select_queue
的驱动程序并不多。 bnx2x 和 ixgbe 驱动程序实现了此功能,但它仅用于以太网光纤通道(FCoE)。 鉴于此,让我们假设网络设备不实现ndo_select_queue
和/或 FCoE 未被使用。 在这种情况下,内核将选择具有 __netdev_pick_tx
。
一旦 __netdev_pick_tx
确定了队列的索引,skb_set_queue_mapping
将缓存该值(稍后将在流量控制代码中使用),netdev_get_tx_queue
将查找并返回指向该队列的指针。 在回到 __dev_queue_xmit
之前,让我们看看 __netdev_pick_tx
是如何工作 。
__netdev_pick_tx
让我们来看看内核如何选择传输队列来传输数据。 来自 ./net/core/flow_dissector.c:
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb){ struct sock *sk = skb->sk; int queue_index = sk_tx_queue_get(sk); if (queue_index < 0 || skb->ooo_okay || queue_index >= dev->real_num_tx_queues) { int new_index = get_xps_queue(dev, skb); if (new_index < 0) new_index = skb_tx_hash(dev, skb); if (queue_index != new_index && sk && rcu_access_pointer(sk->sk_dst_cache)) sk_tx_queue_set(sk, new_index); queue_index = new_index; } return queue_index;}
代码首先调用 sk_tx_queue_get
检查传输队列是否已经缓存在套接字上。如果没有缓存,则返回 -1
。
下一个 if 语句检查以下任一项是否为真:
ooo_okay
标志置位 。 如果设置了该标志,则意味着现在允许乱序数据包。 协议层必须适当地设置此标志。 在流的所有未完成数据包都已确认时,TCP 协议层会设置此标志。 当这种情况发生时,内核可以为该数据包选择不同的传输队列。 UDP 协议层不设置此标志-因此 UDP 数据包永远不会设置 ooo_okay
为非零值。ethtool
更改了设备上的队列计数,则可能会发生这种情况。 稍后会详细介绍。以上任一情况下,代码都会进入慢速路径以获取传输队列。首先调用 get_xps_queue
,它试图使用用户配置映射传输队列到 CPU。这称为“Transmit Packet Steering(XPS)”。我们稍后将更详细地了解 Transmit Packet Steering(XPS) 是什么以及它是如何工作的。
如果 get_xps_queue
返回 -1
,则此内核不支持 XPS,或系统管理员未配置 XPS,或配置的映射指向无效队列,则代码将继续调用 skb_tx_hash
。
一旦使用 XPS 或内核自动使用 skb_tx_hash
选择了队列,将使用 sk_tx_queue_set
缓存该队列到套接字对象上,并返回。在继续 dev_queue_xmit
之前,让我们看看 XPS 和 skb_tx_hash
是如何工作的。
Transmit Packet Steering(XPS)是一项特性,允许系统管理员确定哪些 CPU 可以处理设备的哪些传输队列的传输操作。此功能的主要目的是避免在处理传输请求时出现锁争用。使用 XPS 时,还期望获得其他好处,如减少缓存驱逐和避免在 NUMA 机器 上进行远程内存访问。
您可以 查看 XPS 的内核文档 来了解更多关于 XPS 如何工作的信息。我们将在下面研究如何为您的系统调整 XPS,但现在,您需要知道的是,要配置 XPS,系统管理员可以定义一个位图,映射传输队列到 CPU。
上面代码中调用 get_xps_queue
函数将查询此用户指定的映射,以确定应使用哪个传输队列。如果 get_xps_queue
返回 -1
,则将改用 skb_tx_hash
。
skb_tx_hash
如果内核未包含 XPS,或未配置 XPS,或建议的队列不可用(可能是因为用户调整了队列计数),则 skb_tx_hash
接管以确定发送数据到哪个队列。根据传输工作负载,准确了解 skb_tx_hash
工作原理非常重要。 请注意,这段代码已经随着时间的推移进行了调整,因此如果您使用的内核版本与本文档不同,您应该直接查阅您的内核源代码。
让我们看看它是如何工作的,来自 ./include/linux/netdevice.h:
/* * Returns a Tx hash for the given packet when dev->real_num_tx_queues is used * as a distribution range limit for the returned value. */static inline u16 skb_tx_hash(const struct net_device *dev, const struct sk_buff *skb){ return __skb_tx_hash(dev, skb, dev->real_num_tx_queues);}
代码只是调用 __skb_tx_hash
,来自 ./net/core/flow_dissector.c。这个函数中有一些有趣的代码,让我们来看看:
/* * Returns a Tx hash based on the given packet descriptor a Tx queues' number * to be used as a distribution range. */u16 __skb_tx_hash(const struct net_device *dev, const struct sk_buff *skb, unsigned int num_tx_queues){ u32 hash; u16 qoffset = 0; u16 qcount = num_tx_queues; if (skb_rx_queue_recorded(skb)) { hash = skb_get_rx_queue(skb); while (unlikely(hash >= num_tx_queues)) hash -= num_tx_queues; return hash; }
函数中的第一个 if 语句是一个有趣的短路。函数名 skb_rx_queue_recorded
有些误导。skb 有一个 queue_mapping
字段,用于 rx 和 tx。无论如何,如果您的系统正在接收数据包,并转发它们到其他地方,则此 if 语句为真。如果不是这种情况,则代码继续。
if (dev->num_tc) { u8 tc = netdev_get_prio_tc_map(dev, skb->priority); qoffset = dev->tc_to_txq[tc].offset; qcount = dev->tc_to_txq[tc].count;}
要理解这段代码,重要的是要提到程序可以设置套接字发送数据的优先级。这可以使用 setsockopt
与 SOL_SOCKET
和 SO_PRIORITY
级别和 optname 分别完成。有关 SO_PRIORITY
的更多信息,请参阅 socket(7) 手册页。
请注意,如果您在应用程序中使用了 setsockopt
选项 IP_TOS
来设置特定套接字发送的 IP 数据包的 TOS 标志(或者如果作为辅助消息传递给 sendmsg
则按每个数据包设置),则内核转换您设置的 TOS 选项为优先级,最终进入 skb->priority
。
如前所述,某些网络设备支持基于硬件的流量控制系统。如果 num_tc
非零,则表示此设备支持基于硬件的流量控制。
如果该数字非零,则表示此设备支持基于硬件的流量控制。将查询优先级映射,优先级映射映射数据包优先级到基于硬件的流量控制。根据此映射为数据优先级选择适当的流量类别。
接下来,将生成适合流量类别的传输队列范围。它们将确定传输队列。
如果 num_tc
为零(因为网络设备不支持基于硬件的流量控制),则 qcount
和 qoffset
变量分别设置为传输队列数和 0
。
使用 qcount
和 qoffset
,可以计算传输队列的索引:
if (skb->sk && skb->sk->sk_hash) hash = skb->sk->sk_hash; else hash = (__force u16) skb->protocol; hash = __flow_hash_1word(hash); return (u16) (((u64) hash * qcount) >> 32) + qoffset;}EXPORT_SYMBOL(__skb_tx_hash);
最后,返回适当的队列索引到 __netdev_pick_tx
。
__dev_queue_xmit
此时,已选择适当的传输队列。__dev_queue_xmit
可以继续:
q = rcu_dereference_bh(txq->qdisc);#ifdef CONFIG_NET_CLS_ACT skb->tc_verd = SET_TC_AT(skb->tc_verd, AT_EGRESS);#endif trace_net_dev_queue(skb); if (q->enqueue) { rc = __dev_xmit_skb(skb, q, dev, txq); goto out; }
它首先获得与此队列相关联的排队规则的引用。回想一下,我们之前看到,对于单个传输队列设备,默认值是 pfifo_fast
qdisc,而对于多队列设备,它是 mq
qdisc。
接下来,如果在内核中启用了数据包分类 API,则代码会为传出数据分配一个流量分类“决定”。接下来,检查排队规则是否有方法将数据排队。像 noqueue
qdisc 这样的一些排队规则没有队列。如果有队列,则代码调用 __dev_xmit_skb
来继续处理要传输的数据。之后,执行跳转到此函数的结尾。我们稍后将看一下 __dev_xmit_skb
。现在,让我们看看如果没有队列会发生什么,从一个非常有用的注释开始:
/* The device has no queue. Common case for software devices: loopback, all the sorts of tunnels... Really, it is unlikely that netif_tx_lock protection is necessary here. (f.e. loopback and IP tunnels are clean ignoring statistics counters.) However, it is possible, that they rely on protection made by us here. Check this and shot the lock. It is not prone from deadlocks. Either shot noqueue qdisc, it is even simpler 8) */if (dev->flags & IFF_UP) { int cpu = smp_processor_id(); /* ok because BHs are off */
正如注释所示,唯一可以拥有不带队列的 qdisc 的设备是环回设备和隧道设备。 如果设备当前已启动,则保存当前 CPU。 它用于下一项检查,这有点棘手,让我们来看看:
if (txq->xmit_lock_owner != cpu) { if (__this_cpu_read(xmit_recursion) > RECURSION_LIMIT) goto recursion_alert;
此处有两个分支:该设备队列上的传输锁是否由该 CPU 拥有。 如果是,则在此处检查为每个 CPU 分配的计数器变量 xmit_recursion
,以确定计数是否超过 RECURSION_LIMIT
。 一个程序可能试图发送数据,并在代码中的这个地方被抢占。 调度程序可以选择另一个程序来运行。 如果第二个程序也试图发送数据并运行到这里。 因此,xmit_recursion
计数器防止超过RECURSION_LIMIT
程序此处竞争传输数据。 让我们继续:
HARD_TX_LOCK(dev, txq, cpu); if (!netif_xmit_stopped(txq)) { __this_cpu_inc(xmit_recursion); rc = dev_hard_start_xmit(skb, dev, txq); __this_cpu_dec(xmit_recursion); if (dev_xmit_complete(rc)) { HARD_TX_UNLOCK(dev, txq); goto out; } } HARD_TX_UNLOCK(dev, txq); net_crit_ratelimited("Virtual device %s asks to queue packet!\n", dev->name); } else { /* Recursion is detected! It is possible, * unfortunately */recursion_alert: net_crit_ratelimited("Dead loop on virtual device %s, fix it urgently!\n", dev->name); } }
代码的其余部分首先尝试获取传输锁。检查要使用的设备的传输队列,以查看是否停止传输。如果没有,则增加 xmit_recursion
变量,并传递数据到更靠近设备的位置进行传输。我们稍后会更详细地看到 dev_hard_start_xmit
。完成后,释放锁并打印警告。
另外,如果当前 CPU 是传输锁所有者,或者如果达到了 RECURSION_LIMIT
,则不进行传输,但会打印警告。函数中剩余的代码设置错误码并返回。
由于我们对真实以太网设备感兴趣,因此让我们继续沿着前面 __dev_xmit_skb
为那些设备所采用的代码路径。
__dev_xmit_skb
现在我们从 ./net/core/dev. c 进入 __dev_xmit_skb
,并配备了排队规则、网络设备和传输队列引用:
static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q, struct net_device *dev, struct netdev_queue *txq){ spinlock_t *root_lock = qdisc_lock(q); bool contended; int rc; qdisc_pkt_len_init(skb); qdisc_calculate_pkt_len(skb, q); /* * Heuristic to force contended enqueues to serialize on a * separate lock before trying to get qdisc main lock. * This permits __QDISC_STATE_RUNNING owner to get the lock more often * and dequeue packets faster. */ contended = qdisc_is_running(q); if (unlikely(contended)) spin_lock(&q->busylock);
这段代码首先使用 qdisc_pkt_len_init
和 qdisc_calculate_pkt_len
计算 qdisc 稍后将使用的数据的准确长度。 这对于基于硬件的发送卸载(例如 UDP 分段卸载,如我们之前所看到的)的 skb 是必要的,因为需要考虑在分段发生时添加的附加报头。
接下来,使用一把锁来帮助减少 qdisc 主锁(稍后我们将看到第二把锁)的竞争。 如果 qdisc 当前正在运行,则其他试图传输的程序将竞争 qdisc 的 busylock
。 使得运行中的 qdisc 处理数据包,并与较少数量的程序竞争第二把主锁。 该技巧减少了竞争者的数量,从而增加了吞吐量。 你可以在 这里 阅读描述这一点的原始提交消息。 接下来,主锁被占用:
spin_lock(root_lock);
现在,我们接近一个 if 语句,它处理 3 种可能的情况:
让我们来看看在这些情况下会发生什么,从停用的 qdisc 开始:
if (unlikely(test_bit(__QDISC_STATE_DEACTIVATED, &q->state))) { kfree_skb(skb); rc = NET_XMIT_DROP;
这是直截了当的。 如果 qdisc 已停用,请释放数据并设置返回码为 NET_XMIT_DROP
。 接下来,qdisc 允许数据包旁路,没有其他未完成的数据包,且 qdisc 当前未运行:
} else if ((q->flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) && qdisc_run_begin(q)) { /* * This is a work-conserving queue; there are no old skbs * waiting to be sent out; and the qdisc is not running - * xmit the skb directly. */ if (!(dev->priv_flags & IFF_XMIT_DST_RELEASE)) skb_dst_force(skb); qdisc_bstats_update(q, skb); if (sch_direct_xmit(skb, q, dev, txq, root_lock)) { if (unlikely(contended)) { spin_unlock(&q->busylock); contended = false; } __qdisc_run(q); } else qdisc_run_end(q); rc = NET_XMIT_SUCCESS;
这个 if 语句有点棘手。 如果以下所有条件均为 true
,则整个语句的计算结果为真:
q->flags & TCQ_F_CAN_BYPASS
:qdisc 允许数据包绕过排队系统。 这对于“工作节省”的 qdisc 是 true;即,出于流量整形目的而不延迟数据包传输的 qdisc 被认为是 “工作节省” 的,并且允许数据包绕过。 pfifo_fast
qdisc 允许数据包绕过排队系统。!qdisc_qlen(q)
:qdisc 的队列中没有等待传输的数据。qdisc_run_begin(p)
:此函数调用设置 qdisc 的状态为 “running” 并返回 true,如果 qdisc 已经在运行则返回 false。如果上述所有值均为 true,则:
IFF_XMIT_DST_RELEASE
标志。 如果启用,此标志表示允许内核释放 skb 的目标缓存结构。 此函数中的代码检查标志是否被禁用,并强制对该结构进行引用计数。qdisc_bstats_update
增加 qdisc 发送的字节数和数据包数。sch_direct_xmit
尝试发送数据包。 我们将很快深入研究 sch_direct_xmit
,因为它也用于较慢的代码路径中。在两种情况下检查 sch_direct_xmit
的返回值:
> 0
)。在这种情况下,会释放防止其他程序争用的锁,并调用__qdisc_run
重新启动 qdisc 处理。0
)。在这种情况下,调用 qdisc_run_end
关闭 qdisc 处理。在这两种情况下,返回值 NET_XMIT_SUCCESS
都被设置为返回码。 还不算太糟。 让我们看看最后一个分支,即捕获所有情况:
} else { skb_dst_force(skb); rc = q->enqueue(skb, q) & NET_XMIT_MASK; if (qdisc_run_begin(q)) { if (unlikely(contended)) { spin_unlock(&q->busylock); contended = false; } __qdisc_run(q); }}
在所有其他情况下:
skb_dst_force
强制增加 skb 的目标缓存引用计数。enqueue
函数排队数据到 qdisc。 存储返回码。qdisc_run_begin(p)
标记 qdisc 为正在运行。 如果尚未运行,则释放 busylock
并调用 __qdisc_run(p)
来启动 qdisc 处理。然后,该函数释放一些锁,并返回返回码:
spin_unlock(root_lock);if (unlikely(contended)) spin_unlock(&q->busylock);return rc;
要使 XPS 工作,必须在内核配置中启用它(在 Ubuntu 的内核 3.13.0 上是启用的),并且需要一个位掩码来描述哪些 CPU 应该处理给定接口和传输队列的数据包。
这些位掩码类似于 RPS 位掩码,您可以在内核文档中找到关于这些位掩码的一些 文档。
简而言之,要修改的位掩码位于:
/sys/class/net/DEVICE_NAME/queues/QUEUE/xps_cpus
因此,对于 eth0 和传输队列 0,您需要修改文件:/sys/class/net/eth0/queues/tx-0/xps_cpus
,其中十六进制数指示哪些 CPU 应处理来自 eth0
的传输队列 0 的传输完成。 正如文档所指出的,XPS 在某些配置中可能是不必要的。
要了解网络数据的路径,我们需要稍微了解一下 qdisc 代码。本文不打算涵盖每个不同传输队列选项的具体细节。 如果你对此感兴趣,请查看这本优秀的指南。
在这篇博客文章中,我们将继续代码路径,研究通用包调度器代码是如何工作的。 特别是,我们将探索 qdisc_run_begin
、qdisc_run_end
、__qdisc_run
和 sch_direct_xmit
如何移动网络数据到更靠近传输驱动程序的位置。
让我们先看看 qdisc_run_begin
是如何工作的,并从那里开始。
qdisc_run_begin
和 qdisc_run_end
qdisc_run_begin
函数可以在 ./include/net/sch_generic.h 中找到:
static inline bool qdisc_run_begin(struct Qdisc *qdisc){ if (qdisc_is_running(qdisc)) return false; qdisc->__state |= __QDISC___STATE_RUNNING; return true;}
这个函数很简单:检查 qdisc 的 __state
标志。 如果它已经在运行,则返回 false
。 否则,更新 __state
以启用 __QDISC___STATE_RUNNING
位。
同样,qdisc_run_end
也是寡淡的:
static inline void qdisc_run_end(struct Qdisc *qdisc){ qdisc->__state &= ~__QDISC___STATE_RUNNING;}
它只是禁用 qdisc __state
字段中的 __QDISC__STATE_RUNNING
位。 需要注意的是,这两个函数都只是翻转位;自己既不实际开始,也不停止处理。 另一方面,函数 __qdisc_run
实际上开始处理。
__qdisc_run
__qdisc_run
看起来很简短:
void __qdisc_run(struct Qdisc *q){ int quota = weight_p; while (qdisc_restart(q)) { /* * Ordered by possible occurrence: Postpone processing if * 1. we've exceeded packet quota * 2. another process needs the CPU; */ if (--quota <= 0 || need_resched()) { __netif_schedule(q); break; } } qdisc_run_end(q);}
该函数首先获取 weight_p
值。 该值通常是 sysctl 设置的,也会在接收路径中使用。我们稍后会看到如何调整这个值。 这个循环做两件事:
qdisc_restart
,直到返回 false(或者触发下面的 break)。need_resched()
返回 true。 如果其中一个为 true
,则调用 __netif_schedule
并中断循环。记住:到现在为止,内核仍然在执行代表用户程序对 sendmsg
的原始调用;用户程序当前正在累积系统时间。 如果用户程序已经用完了内核中的时间配额,那么 need_resched
将返回 true。 如果仍然有可用的配额,并且用户程序尚未使用完其时间片,qdisc_restart
将再次被调用。
让我们看看 qdisc_restart(q)
是如何工作的,然后我们将深入研究 __netif_schedule(q)
。
qdisc_restart
让我们跳到 qdisc_restart
的代码中:
/* * NOTE: Called under qdisc_lock(q) with locally disabled BH. * * __QDISC_STATE_RUNNING guarantees only one CPU can process * this qdisc at a time. qdisc_lock(q) serializes queue accesses for * this queue. * * netif_tx_lock serializes accesses to device driver. * * qdisc_lock(q) and netif_tx_lock are mutually exclusive, * if one is grabbed, another must be free. * * Note, that this procedure can be called by a watchdog timer * * Returns to the caller: * 0 - queue is empty or throttled. * >0 - queue is not empty. * */static inline int qdisc_restart(struct Qdisc *q){ struct netdev_queue *txq; struct net_device *dev; spinlock_t *root_lock; struct sk_buff *skb; /* Dequeue packet */ skb = dequeue_skb(q); if (unlikely(!skb)) return 0; WARN_ON_ONCE(skb_dst_is_noref(skb)); root_lock = qdisc_lock(q); dev = qdisc_dev(q); txq = netdev_get_tx_queue(dev, skb_get_queue_mapping(skb)); return sch_direct_xmit(skb, q, dev, txq, root_lock);}
qdisc_restart
函数以一个有用的注释开始,该注释描述了调用此函数的一些加锁约束。 此函数执行的第一个操作是尝试从 qdisc 出队 skb。
函数 dequeue_skb
尝试获得下一个要传输的数据包。 如果队列为空 qdisc_restart
将返回 false(导致 __qdisc_run
退出)。
假设存在要传输的数据,则代码继续获取 qdisc 队列锁、qdisc 的关联设备和传输队列的引用。
所有这些都会传递到 sch_direct_xmit
。 让我们先看一下 dequeue_skb
,然后再看 sch_direct_xmit
。
dequeue_skb
让我们看一下 ./net/sched/sch_generic.c 中的 dequeue_skb
。 此函数处理两种主要情况:
我们来看一下第一个案例:
static inline struct sk_buff *dequeue_skb(struct Qdisc *q){ struct sk_buff *skb = q->gso_skb; const struct netdev_queue *txq = q->dev_queue; if (unlikely(skb)) { /* check the reason of requeuing without tx lock first */ txq = netdev_get_tx_queue(txq->dev, skb_get_queue_mapping(skb)); if (!netif_xmit_frozen_or_stopped(txq)) { q->gso_skb = NULL; q->q.qlen--; } else skb = NULL;
请注意,该代码首先引用 qdisc 的 gso_skb
字段。 此字段保存重新排队的数据的引用。 如果未重新排队数据,则此字段将为 NULL
。 如果该字段不为 NULL
,则代码继续获取数据的传输队列并检查队列是否停止。 如果队列没有停止,则清除 gso_skb
字段,并且减少队列长度计数器。 如果队列停止,数据仍然关联到 gso_skb
,但此函数将返回 NULL
。
让我们检查下一个案例,其中没有重新排队的数据:
} else { if (!(q->flags & TCQ_F_ONETXQUEUE) || !netif_xmit_frozen_or_stopped(txq)) skb = q->dequeue(q); } return skb;}
在没有数据被重新排队的情况下,另一个复杂的复合 if 语句被求值。 如果:
然后,调用 qdisc 的 dequeue
函数以获取新数据。 dequeue
的内部实现根据 qdisc 的实现和特性而有所不同。
该函数以返回待处理的数据结束。
sch_direct_xmit
现在我们来看看 sch_direct_xmit
(在 ./net/sched/sch_generic.c 中),它是向下移动数据到网络设备的重要参与者。 让我们一点一点地来看看:
/* * Transmit one skb, and handle the return status as required. Holding the * __QDISC_STATE_RUNNING bit guarantees that only one CPU can execute this * function. * * Returns to the caller: * 0 - queue is empty or throttled. * >0 - queue is not empty. */int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q, struct net_device *dev, struct netdev_queue *txq, spinlock_t *root_lock){ int ret = NETDEV_TX_BUSY; /* And release qdisc */ spin_unlock(root_lock); HARD_TX_LOCK(dev, txq, smp_processor_id()); if (!netif_xmit_frozen_or_stopped(txq)) ret = dev_hard_start_xmit(skb, dev, txq); HARD_TX_UNLOCK(dev, txq);
该代码首先释放 qdisc 锁,然后锁定传输锁。 注意,HARD_TX_LOCK
是一个宏:
#define HARD_TX_LOCK(dev, txq, cpu) { \ if ((dev->features & NETIF_F_LLTX) == 0) { \ __netif_tx_lock(txq, cpu); \ } \}
此宏检查设备功能标志中是否设置了 NETIF_F_LLTX
标志。 此标志已弃用,新设备驱动程序不应使用此标志。 此内核版本中的大多数驱动程序都不使用此标志,因此此检查将评估为 true,并将获得此数据的传输队列的锁。
接下来,检查传输队列以确保它没有停止,然后调用 dev_hard_start_xmit
。 我们将在后面看到,dev_hard_start_xmit
从 Linux 内核的网络设备子系统转换网络数据到设备驱动程序本身以进行传输。 存储此函数的返回码,然后检查该返回码以确定传输是否成功。
一旦这已经运行(或者由于队列停止而被跳过),则释放队列的传输锁。 让我们继续:
spin_lock(root_lock);if (dev_xmit_complete(ret)) { /* Driver sent out skb successfully or skb was consumed */ ret = qdisc_qlen(q);} else if (ret == NETDEV_TX_LOCKED) { /* Driver try lock failed */ ret = handle_dev_cpu_collision(skb, txq, q);
接下来,再次获取此 qdisc 的锁,然后检查 dev_hard_start_xmit
。 第一种情况是调用 dev_xmit_complete
检查,它只是检查返回值以确定数据是否成功发送。 如果是,则设置 qdisc 队列长度为返回值。
如果 dev_xmit_complete
返回 false,则将检查返回值以查看 dev_hard_start_xmit
是否从设备驱动程序返回 NETDEV_TX_LOCKED
。 当驱动程序尝试自己锁定传输队列并失败时,具有不推荐使用的 NETIF_F_LLTX
功能标志的设备可以返回 NETDEV_TX_LOCKED
。 在这种情况下,调用 handle_dev_cpu_collision
来处理锁竞争。 我们稍后会仔细研究 handle_dev_cpu_collision
,但现在,让我们继续 sch_direct_xmit
并查看捕获所有的分支:
} else { /* Driver returned NETDEV_TX_BUSY - requeue skb */ if (unlikely(ret != NETDEV_TX_BUSY)) net_warn_ratelimited("BUG %s code %d qlen %d\n", dev->name, ret, q->q.qlen); ret = dev_requeue_skb(skb, q);}
因此,如果驱动程序没有传输数据,并且传输锁未被持有,则可能是由于 NETDEV_TX_BUSY
(如果没有打印警告)。NETDEV_TX_BUSY
可以由驱动程序返回,以指示设备或驱动程序“忙碌”并且现在不能传输数据。 在本例中,调用 dev_requeue_skb
将要重试的数据重新入队。
该函数(可能)调整返回值来结束:
if (ret && netif_xmit_frozen_or_stopped(txq)) ret = 0;return ret;
让我们深入了解 handle_dev_cpu_collision
和 dev_requeue_skb
。
handle_dev_cpu_collision
来自 ./net/sched/sch_generic.c 的代码 handle_dev_cpu_collision
处理两种情况:
在第一种情况下,这被作为配置问题处理,因此打印警告。 在第二种情况下,增加统计计数器cpu_collision
,并且数据经 dev_requeue_skb
发送,以便稍后重新排队传输。 回想一下,我们在 dequeue_skb
中看到的专门处理重新排队的 skb 代码。
handle_dev_cpu_collision
的代码很短,值得快速阅读:
static inline int handle_dev_cpu_collision(struct sk_buff *skb, struct netdev_queue *dev_queue, struct Qdisc *q){ int ret; if (unlikely(dev_queue->xmit_lock_owner == smp_processor_id())) { /* * Same CPU holding the lock. It may be a transient * configuration error, when hard_start_xmit() recurses. We * detect it by checking xmit owner and drop the packet when * deadloop is detected. Return OK to try the next skb. */ kfree_skb(skb); net_warn_ratelimited("Dead loop on netdevice %s, fix it urgently!\n", dev_queue->dev->name); ret = qdisc_qlen(q); } else { /* * Another cpu is holding lock, requeue & delay xmits for * some time. */ __this_cpu_inc(softnet_data.cpu_collision); ret = dev_requeue_skb(skb, q); } return ret;}
让我们来看看 dev_requeue_skb
做了什么,因为我们将看到这个函数是从 sch_direct_xmit
调用的。
dev_requeue_skb
值得庆幸的是,dev_requeue_skb
的源代码很短,而且直截了当,来自 ./net/sched/sch_generic.c:
/* Modifications to data participating in scheduling must be protected with * qdisc_lock(qdisc) spinlock. * * The idea is the following: * - enqueue, dequeue are serialized via qdisc root lock * - ingress filtering is also serialized via qdisc root lock * - updates to tree and tree walking are only done under the rtnl mutex. */static inline int dev_requeue_skb(struct sk_buff *skb, struct Qdisc *q){ skb_dst_force(skb); q->gso_skb = skb; q->qstats.requeues++; q->q.qlen++; /* it's still part of the queue */ __netif_schedule(q); return 0;}
这个函数做了几件事:
gso_skb
字段。 回想一下,我们之前看到,在从 qdisc 的队列中取出数据之前,会在 dequeue_skb
中检查此字段。__netif_schedule
。简单明了。 让我们回顾一下我们是如何到达这里的,然后探讨 __netif_schedule
。
__qdisc_run
中的 while 循环回想一下,我们是检查函数 __qdisc_run
得出的这一点,该函数包含以下代码:
void __qdisc_run(struct Qdisc *q){ int quota = weight_p; while (qdisc_restart(q)) { /* * Ordered by possible occurrence: Postpone processing if * 1. we've exceeded packet quota * 2. another process needs the CPU; */ if (--quota <= 0 || need_resched()) { __netif_schedule(q); break; } } qdisc_run_end(q);}
这段代码的工作原理是在一个循环中反复调用 qdisc_restart
,在内部,它会使 skb 出队,并试图调用 sch_direct_xmit
来传输 skb,而 sch_direct_xmit 会调用 dev_hard_start_xmit
来执行实际的传输。 任何不能传输的内容都将在 NET_TX
软中断中重新排队以进行传输。
传输过程中的下一步是检查 dev_hard_start_xmit
,以了解如何调用驱动程序来发送数据。 在此之前,我们应该研究 __netif_schedule
以完全理解 __qdisc_run
和 dev_requeue_skb
是如何工作的。
__netif_schedule
让我们从 ./net/core/dev.c 跳到 __netif_schedule
:
void __netif_schedule(struct Qdisc *q){ if (!test_and_set_bit(__QDISC_STATE_SCHED, &q->state)) __netif_reschedule(q);}EXPORT_SYMBOL(__netif_schedule);
此代码检查并设置 qdisc 状态的 __QDISC_STATE_SCHED
位。 如果该位被翻转(意味着它之前没有处于 __QDISC_STATE_SCHED
状态),代码将调用 __netif_reschedule
,这并不长,但有非常有趣的附带作用。 我们来看一下:
static inline void __netif_reschedule(struct Qdisc *q){ struct softnet_data *sd; unsigned long flags; local_irq_save(flags); 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); local_irq_restore(flags);}
此函数执行以下操作:
local_irq_save
禁用 IRQ。softnet_data
结构。softnet_data
的输出队列。NET_TX_SOFTIRQ
软中断。你可以阅读我们之前关于网络栈接收端的文章,来了解更多关于 softnet_data
数据结构初始化的信息。
上面函数中的重要代码是:raise_softirq_irqoff
触发 NET_TX_SOFTIRQ
软中断。softirq 及其注册也在我们的前一篇文章中介绍过。 简单地说,您可以认为软中断是内核线程,它们以非常高的优先级执行,并代表内核处理数据。 它们处理传入的网络数据,也处理传出的数据。
正如你在上一篇文章中看到的,NET_TX_SOFTIRQ
软中断注册了函数 net_tx_action
。这意味着有一个内核线程在执行 net_tx_action
。 该线程偶尔会暂停,raise_softirq_irqoff
会恢复它。让我们来看看 net_tx_action
是做什么的,这样我们就可以理解内核是如何处理传输请求的。
net_tx_action
net_tx_action
函数位于 ./net/core/dev.c 文件中,它在运行时处理两个主要内容:
softnet_data
结构的完成队列。softnet_data
结构的输出队列。实际上,该函数的代码是两个大的 if 块。 让我们一次查看一个,同时记住这段代码是作为一个独立的内核线程在软中断上下文中执行的。 net_tx_action
的目的是在整个网络对战的传输侧执行不能在热点路径中执行的代码;工作被延迟,稍后由执行 net_tx_action
的线程进行处理。
net_tx_action
完成队列softnet_data
的完成队列只是一个等待释放的 skb 队列。 函数 dev_kfree_skb_irq
添加 skb 到队列中以便稍后释放。 设备驱动程序通常使用此选项来延迟释放已使用的 skb。 驱动程序希望延迟释放 skb 而不是简单地释放 skb,原因是释放内存可能需要时间,在某些实例(如 hardirq 处理程序)中,代码需要尽可能快地执行并返回。
看一下 net_tx_action
代码,它处理在完成队列上释放 skb:
if (sd->completion_queue) { struct sk_buff *clist; local_irq_disable(); clist = sd->completion_queue; sd->completion_queue = NULL; local_irq_enable(); while (clist) { struct sk_buff *skb = clist; clist = clist->next; WARN_ON(atomic_read(&skb->users)); trace_kfree_skb(skb, net_tx_action); __kfree_skb(skb); }}
如果完成队列有条目,while
循环将遍历 skb 的链表,并对每个 skb 调用 __kfree_skb
以释放它们的内存。 请记住,这段代码是在一个单独的“线程”中运行的,该线程名为 softirq – 它并不代表任何特定的用户程序运行。
net_tx_action
输出队列输出队列的用途完全不同。 如前所述,调用 __netif_reschedule
添加数据到输出队列,该调用通常从 __netif_schedule
调用的。 到目前为止,在我们在两个实例中看到过调用了 __netif_schedule
函数:
dev_requeue_skb
:正如我们所看到的,如果驱动程序报告错误码 NETDEV_TX_BUSY
或 CPU 冲突,则可以调用此函数。__qdisc_run
:我们之前也看到过这个函数。 一旦超过配额或需要重新调度进程,它还会调用 __netif_schedule
。在这两种情况下,都将调用 __netif_schedule
函数,该函数添加 qdisc 到 softnet_data
的输出队列中进行处理。 我将输出队列处理代码分成了三个块。 我们先来看看第一个:
if (sd->output_queue) { struct Qdisc *head; local_irq_disable(); head = sd->output_queue; sd->output_queue = NULL; sd->output_queue_tailp = &sd->output_queue; local_irq_enable();
这个块只是确保输出队列上有 qdisc,如果有,它设置 head
为第一个条目,并移动队列的尾指针。
接下来,遍历 qdsics 列表的 while
循环开始:
while (head) { struct Qdisc *q = head; spinlock_t *root_lock; head = head->next_sched; root_lock = qdisc_lock(q); if (spin_trylock(root_lock)) { smp_mb__before_clear_bit(); clear_bit(__QDISC_STATE_SCHED, &q->state); qdisc_run(q); spin_unlock(root_lock);
上面的代码段向前移动头指针,并获得对 qdisc 锁的引用。spin_trylock
检查是否可以获得锁;注意,该调用是专门使用的,因为它不阻塞。 如果锁已经被持有,spin_trylock
将立即返回,而不是等待获得锁。
如果 spin_trylock
成功获得锁,则返回一个非零值。 在这种情况下,qdisc 的状态字段的__QDISC_STATE_SCHED
位翻转,qdisc_run
被调用,从而翻转 __QDISC___STATE_RUNNING
位,并开始执行 __qdisc_run
。
这很重要。这里发生的情况是,我们之前检查过的代表用户进行系统调用的处理循环,现在再次运行,但在 softirq 上下文中,因为此 qdisc 的 skb 传输无法传输。 这种区别很重要,因为它会影响您如何监控发送大量数据的应用程序的 CPU 使用情况。 让我换个方式说:
因此,发送数据所花费的总时间是与发送相关的系统调用的系统时间和 NET_TX
软中断的软中断时间的组合。
无论如何,上面的代码释放 qdisc 锁来完成。 如果上面获取锁的 spin_trylock
调用失败,则执行以下代码:
} else { if (!test_bit(__QDISC_STATE_DEACTIVATED, &q->state)) { __netif_reschedule(q); } else { smp_mb__before_clear_bit(); clear_bit(__QDISC_STATE_SCHED, &q->state); } } }}
这段代码只在无法获得 qdisc 锁时执行,它处理两种情况。 两者之一:
qdisc_run
的锁。 所以,调用 __netif_reschedule
。 在这里调用 __netif_reschedule
会将 qdisc 放回该函数当前出列的队列中。这允许在以后可能已经放弃锁时再次检查 qdisc。__QDISC_STATE_SCHED
状态标志也被清除。dev_hard_start_xmit
因此,我们已经遍历了整个网络栈,直到 dev_hard_start_xmit
。 也许你是经 sendmsg
系统调用直接到达这里的,或者你是经 qdisc 上处理网络数据的 softirq 线程到达这里的。dev_hard_start_xmit
将向下调用设备驱动程序来实际执行传输操作。
dev_hard_start_xmit
函数处理两种主要情况:
我们将看到这两种情况是如何处理的,从准备发送的网络数据开始。 让我们一起来看看(如下所示:./net/code/dev.c:
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev, struct netdev_queue *txq){ const struct net_device_ops *ops = dev->netdev_ops; int rc = NETDEV_TX_OK; unsigned int skb_len; if (likely(!skb->next)) { netdev_features_t features; /* * If device doesn't need skb->dst, release it right now while * its hot in this cpu cache */ if (dev->priv_flags & IFF_XMIT_DST_RELEASE) skb_dst_drop(skb); features = netif_skb_features(skb);
这段代码首先 ops
获取设备驱动程序暴露的操作的引用。当需要驱动程序执行一些工作来传输数据时,将使用它。 代码检查 skb->next
以确保此数据不是数据链的一部分,该数据链已分段准备就绪,并继续执行两件事:
IFF_XMIT_DST_RELEASE
标志。 此内核中的任何“真正的”以太网设备都不使用此标志。 但是,它被环回设备和其他一些软件设备使用。 如果启用此标志,则可以减少目标缓存条目的引用计数,因为驱动程序不需要它。netif_skb_features
从设备获取特性标志,并根据数据的目的协议(dev->protocol
)对它们进行一些修改。 例如,如果协议是设备可以校验和的协议,则标记 skb 为这样的协议。 VLAN 标记(如果已设置)也会导致其他功能标志翻转。接下来,将检查 VLAN 标记,如果设备无法卸载 VLAN 标记,则将在软件中__vlan_put_tag
来执行此操作:
if (vlan_tx_tag_present(skb) && !vlan_hw_offload_capable(features, skb->vlan_proto)) { skb = __vlan_put_tag(skb, skb->vlan_proto, vlan_tx_tag_get(skb)); if (unlikely(!skb)) goto out; skb->vlan_tci = 0;}
接下来,将检查数据是否是封装卸载请求,例如,可能是 GRE。 在这种情况下,更新功能标志,以包括可用的任何特定于设备的硬件封装功能:
/* If encapsulation offload request, verify we are testing * hardware encapsulation features instead of standard * features for the netdev */if (skb->encapsulation) features &= dev->hw_enc_features;
接下来,netif_needs_gso
来确定 skb 本身是否需要分段。 如果 skb 需要分段,但设备不支持,则 netif_needs_gso
将返回 true
指示分段应在软件中进行。 在本例中,调用dev_gso_segment
来执行分段,代码将跳转到 gso
来传输数据包。 稍后我们将看到 GSO 路径。
if (netif_needs_gso(skb, features)) { if (unlikely(dev_gso_segment(skb, features))) goto out_kfree_skb; if (skb->next) goto gso;}
如果数据不需要分割,则处理一些其他情况。 第一:数据是否需要线性化? 也就是说,如果数据分布在多个缓冲区中,设备是否可以支持发送网络数据,或者是否需要首先组合所有数据到单个线性缓冲区中? 绝大多数网卡不需要在传输之前对数据进行线性化,因此在几乎所有情况下,这将被计算为 false 并跳过。
else { if (skb_needs_linearize(skb, features) && __skb_linearize(skb)) goto out_kfree_skb;
接下来提供了一个有用的注释,解释了下一个分支。 检查数据包以确定它是否仍需要校验和。 如果设备不支持校验和,则在软件中生成校验和:
/* If packet is not checksummed and device does not * support checksumming for this protocol, complete * checksumming here. */ if (skb->ip_summed == CHECKSUM_PARTIAL) { if (skb->encapsulation) skb_set_inner_transport_header(skb, skb_checksum_start_offset(skb)); else skb_set_transport_header(skb, skb_checksum_start_offset(skb)); if (!(features & NETIF_F_ALL_CSUM) && skb_checksum_help(skb)) goto out_kfree_skb; }}
现在我们继续讨论数据包抓取!回想一下,在 接收端博客文章 中,我们看到了如何传递数据包给数据包抓取(例如 PCAP)。此函数中的下一块代码将即将传输的数据包交给数据包抓取(如果有的话)。
if (!list_empty(&ptype_all)) dev_queue_xmit_nit(skb, dev);
最后,驱动程序的 ops
调用 ndo_start_xmit
向下传递数据到设备:
skb_len = skb->len; rc = ops->ndo_start_xmit(skb, dev); trace_net_dev_xmit(skb, rc, dev, skb_len); if (rc == NETDEV_TX_OK) txq_trans_update(txq); return rc;}
返回 ndo_start_xmit
的返回值,指示数据包是否被传输。 我们看到了这个返回值将如何影响上层:由该函数调用方的 QDisc 重新排队数据,以便它可以稍后再次传输。
让我们来看看 GSO 的案例。 如果 skb 已经由于在此函数中发生的分段,而被分离成一个数据包链,或者先前分段但未能发送并排队等待再次发送的数据包,则此代码将运行。
gso: do { struct sk_buff *nskb = skb->next; skb->next = nskb->next; nskb->next = NULL; if (!list_empty(&ptype_all)) dev_queue_xmit_nit(nskb, dev); skb_len = nskb->len; rc = ops->ndo_start_xmit(nskb, dev); trace_net_dev_xmit(nskb, rc, dev, skb_len); if (unlikely(rc != NETDEV_TX_OK)) { if (rc & ~NETDEV_TX_MASK) goto out_kfree_gso_skb; nskb->next = skb->next; skb->next = nskb; return rc; } txq_trans_update(txq); if (unlikely(netif_xmit_stopped(txq) && skb->next)) return NETDEV_TX_BUSY; } while (skb->next);
您可能已经猜到了,这段代码是一个 while 循环,它遍历在数据分段时生成的 skb 列表。
每个数据包:
ndo_start_xmit
传递给驱动器进行传输。传输数据包中的任何错误都会调整需要发送的 skb 列表来处理。 错误将返回堆栈,未发送的 skb 可能会被重新排队,以便稍后再次发送。
此函数的最后一部分处理清理,并可能在出现上述错误时释放数据:
out_kfree_gso_skb: if (likely(skb->next == NULL)) { skb->destructor = DEV_GSO_CB(skb)->destructor; consume_skb(skb); return rc; }out_kfree_skb: kfree_skb(skb);out: return rc;}EXPORT_SYMBOL_GPL(dev_hard_start_xmit);
在继续讨论设备驱动程序之前,让我们看一下可以对我们刚刚浏览的代码进行的一些监控和调优。
tc
命令行工具使用 tc
监控您的 qdisc 统计数据
$ tc -s qdisc show dev eth1qdisc mq 0: root Sent 31973946891907 bytes 2298757402 pkt (dropped 0, overlimits 0 requeues 1776429) backlog 0b 0p requeues 1776429
为了监控系统的数据包传输状况,检查连接到网络设备的队列规则的统计信息至关重要。 您可以运行命令行工具 tc
来检查状态。 上面的示例显示了如何检查 eth1
接口的统计信息。
bytes
:下推到驱动程序进行传输的字节数。pkt
:下推到驱动程序进行传输的数据包数量。dropped
:qdisc 丢弃的数据包数。 如果传输队列长度不足以容纳排队的数据,则可能发生这种情况。overlimits
:取决于排队规则,但可以是由于达到限制而无法入队的数据包数量,和/或在出队时触发节流事件的数据包数量。requeues
:调用 dev_requeue_skb
重新排队 skb 的次数。 请注意,多次重新排队的 skb 将在每次重新排队时增加此计数器。backlog
:当前在 qdisc 队列中的字节数。 这个数字通常在每次数据包入队时增加。某些 qdics 可能会导出其他统计信息。 每个 qdisc 是不同的,并且可以在不同的时间增加这些计数器。 您可能需要研究您正在使用的 qdisc 的源代码,以准确了解这些值何时可以在您的系统上增加,从而帮助了解对您的影响。
__qdisc_run
您可以调整前面看到 __qdisc_run
循环的权重(上面看到的quota
变量),这将导致执行更多__netif_schedule
的调用。 结果是当前 qdisc 更多次被添加到当前 CPU 的 output_queue
列表中,这应该会导致对传输数据包的额外处理。
示例:使用 sysctl
增加所有 qdisc 的 __qdisc_run
配额。
$ sudo sysctl -w net.core.dev_weight=600
每个网络设备都有一个可以修改的 txqueuelen
调节旋钮。大多数 qdisc 在对最终应由 qdisc 传输的数据排队时,都会检查设备是否具有足够的 txqueuelen
字节。您可以调整此参数以增加 qdisc 可排队的字节数。
示例:增加 eth0
的 txqueuelen
到 10000
。
$ sudo ifconfig eth0 txqueuelen 10000
以太网设备的默认值为 1000
。 您可以读取 ifconfig
的输出来检查网络设备的 txqueuelen
。
我们的旅程就要结束了。 关于数据包传输有一个重要的概念需要理解。 大多数设备和驱动程序将数据包传输处理分为两步过程:
第二阶段通常被称为“传输完成”阶段。 我们将研究这两个阶段,但我们将从第一阶段开始:传输阶段。
我们看到 dev_hard_start_xmit
调用了 ndo_start_xmit
(持有锁)来传输数据,所以让我们从检查驱动程序如何注册 ndo_start_xmit
开始,然后我们将深入研究该函数如何工作。
和 上一篇博文一样, 我们将研究 igb
驱动程序。
驱动程序为各种操作实现一系列功能,例如:
ndo_start_xmit
)ndo_get_stats64
)ioctls
(ndo_do_ioctl
)函数被导出为一系列排列在结构中的函数指针。 让我们来看看 igb
驱动程序源代码中这些操作的结构定义:
static const struct net_device_ops igb_netdev_ops = { .ndo_open = igb_open, .ndo_stop = igb_close, .ndo_start_xmit = igb_xmit_frame, .ndo_get_stats64 = igb_get_stats64,/* ... more fields ... */};
此结构在 igb_probe
函数中注册:
static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent){/* ... lots of other stuff ... */ netdev->netdev_ops = &igb_netdev_ops;/* ... more code ... */}
正如我们在上一节中看到的,更高层的代码将获得对设备的 netdev_ops
结构的引用,并调用相应的函数。 如果你想了解更多关于 PCI 设备是如何启动的,以及何时/何地调用 igb_probe
的信息,请查看我们的其他博客文章中的驱动程序初始化部分。
ndo_start_xmit
传输数据网络栈的较高层使用 net_device_ops
结构调用驱动程序来执行各种操作。 正如我们前面看到的,qdisc 代码调用 ndo_start_xmit
传递数据给驱动程序进行传输。 对于大多数硬件设备,ndo_start_xmit
函数在锁被持有时被调用,正如我们上面看到的。
在 igb
设备驱动程序中,注册到 ndo_start_xmit
称为 igb_xmit_frame
,因此让我们从igb_xmit_frame
开始,了解此驱动程序如何传输数据。 进入 ./drivers/net/ethernet/intel/igb/igb_main.c ,并记住,在执行以下代码的整个过程中,都会持有一个锁:
netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb, struct igb_ring *tx_ring){ struct igb_tx_buffer *first; int tso; u32 tx_flags = 0; u16 count = TXD_USE_COUNT(skb_headlen(skb)); __be16 protocol = vlan_get_protocol(skb); u8 hdr_len = 0; /* need: 1 descriptor per page * PAGE_SIZE/IGB_MAX_DATA_PER_TXD, * + 1 desc for skb_headlen/IGB_MAX_DATA_PER_TXD, * + 2 desc gap to keep tail from touching head, * + 1 desc for context descriptor, * otherwise try next time */ if (NETDEV_FRAG_PAGE_MAX_SIZE > IGB_MAX_DATA_PER_TXD) { unsigned short f; for (f = 0; f < skb_shinfo(skb)->nr_frags; f++) count += TXD_USE_COUNT(skb_shinfo(skb)->frags[f].size); } else { count += skb_shinfo(skb)->nr_frags; }
该函数开始使用 TXD_USER_COUNT
宏来确定需要多少个传输描述符来传输传入的数据。 count
值初始化为适合 skb 的描述符数量。 然后考虑需要传输的任何附加片段,对其进行调整。
if (igb_maybe_stop_tx(tx_ring, count + 3)) { /* this is a hard error */ return NETDEV_TX_BUSY;}
然后驱动程序调用一个内部函数 igb_maybe_stop_tx
,该函数检查所需的描述符数量,以确保传输队列有足够的可用资源。 如果没有,则在此处返回 NETDEV_TX_BUSY
。 正如我们前面在 qdisc 代码中看到的,这将导致 qdisc 重新排队数据以便稍后重试。
/* record the location of the first descriptor for this packet */first = &tx_ring->tx_buffer_info[tx_ring->next_to_use];first->skb = skb;first->bytecount = skb->len;first->gso_segs = 1;
然后,代码获得对传输队列中的下一个可用缓冲区信息的引用。 此结构将跟踪稍后设置缓冲区描述符所需的信息。 对数据包的引用及其大小被复制到缓冲区信息结构中。
skb_tx_timestamp(skb);
上面的代码调用 skb_tx_timestamp
获得基于软件的发送时间戳。 应用程序可以使用发送时间戳来确定数据包通过网络栈的传输路径所花费的时间量。
一些设备还支持为在硬件中传输的数据包生成时间戳。 这允许系统卸载时间戳到设备,并且它允许程序员获得更准确的时间戳,因为它将更接近硬件的实际传输发生的时间。 现在我们来看看这段代码:
if (unlikely(skb_shinfo(skb)->tx_flags & SKBTX_HW_TSTAMP)) { struct igb_adapter *adapter = netdev_priv(tx_ring->netdev); if (!(adapter->ptp_tx_skb)) { skb_shinfo(skb)->tx_flags |= SKBTX_IN_PROGRESS; tx_flags |= IGB_TX_FLAGS_TSTAMP; adapter->ptp_tx_skb = skb_get(skb); adapter->ptp_tx_start = jiffies; if (adapter->hw.mac.type == e1000_82576) schedule_work(&adapter->ptp_tx_work); }}
一些网络设备可以使用精确时间协议在硬件中对数据包加时间戳。 当用户请求硬件时间戳时,驱动程序代码将在此处处理此问题。
上面的 if
语句检查 SKBTX_HW_TSTAMP
标志。 此标志指示用户请求了硬件时间戳。 如果用户请求了硬件时间戳,代码接下来检查是否设置 ptp_tx_skb
。 一次可以对一个数据包加时间戳,,因此在此处获取正在进行时间戳的数据包的引用,并在 skb 上设置 SKBTX_IN_PROGRESS
标志。 更新 tx_flags
以标记 IGB_TX_FLAGS_TSTAMP
标志。 变量稍后复制 tx_flags
到 buffer info 结构中。
获取 skb 的引用,复制当前 jiffies 计数到 ptp_tx_start
。驱动程序中的其他代码将使用此值来确保 TX 硬件时间戳不会挂起。最后,如果这是一个 82576
以太网硬件适配器,则使用 schedule_work
函数来启动 工作队列。
if (vlan_tx_tag_present(skb)) { tx_flags |= IGB_TX_FLAGS_VLAN; tx_flags |= (vlan_tx_tag_get(skb) << IGB_TX_FLAGS_VLAN_SHIFT);}
上面的代码检查是否设置了 skb 的 vlan_tci
字段。 如果已设置,则启用IGB_TX_FLAGS_VLAN
标志并存储 vlan ID。
/* record initial flags and protocol */first->tx_flags = tx_flags;first->protocol = protocol;
标志和协议被记录到缓冲区信息结构。
tso = igb_tso(tx_ring, first, &hdr_len);if (tso < 0) goto out_drop;else if (!tso) igb_tx_csum(tx_ring, first);
接下来,驱动程序调用其内部函数 igb_tso
。 此函数确定 skb 是否需要分段。 如果是,则缓冲器信息引用(first
)更新其标志以向硬件指示需要 TSO。
如果 tso 不必要,igb_tso
将返回 0
,否则返回 1
。 如果返回 0
,igb_tx_csum
来处理启用校验和卸载(如果需要并且该协议支持)。 igb_tx_csum
函数检查 skb 的属性,并首先翻转 缓冲区 first
中的一些标志位,以指示需要卸载校验和。
igb_tx_map(tx_ring, first, hdr_len);
调用 igb_tx_map
函数来准备设备要消耗的数据以进行传输。 接下来我们将详细研究这个函数。
/* Make sure there is space in the ring for the next send. */igb_maybe_stop_tx(tx_ring, DESC_NEEDED);return NETDEV_TX_OK;
传输完成后,驱动程序进行检查,以确保有足够的空间可用于另一次传输。 如果没有,则关闭队列。 在任何一种情况下,NETDEV_TX_OK
都会返回到更高层(qdisc 代码)。
out_drop: igb_unmap_and_free_tx_resource(tx_ring, first); return NETDEV_TX_OK;}
最后是一些错误处理代码。 这段代码只在 igb_tso
遇到某种错误时才被命中。 igb_unmap_and_free_tx_resource
清理数据。在这种情况下也返回 NETDEV_TX_OK
。 传输不成功,但驱动程序释放了关联的资源,没有什么可做的了。 请注意,在这种情况下,此驱动程序不会增加数据包丢弃,但它可能应该这样做。
igb_tx_map
igb_tx_map
函数处理映射 skb 数据到 RAM 的可 DMA 区域的细节。 它还更新设备上的传输队列的尾指针,这是触发设备“唤醒”、从 RAM 获取数据,并开始传输数据。
让我们简单地看看这个函数是如何工作的:
static void igb_tx_map(struct igb_ring *tx_ring, struct igb_tx_buffer *first, const u8 hdr_len){ struct sk_buff *skb = first->skb;/* ... other variables ... */ u32 tx_flags = first->tx_flags; u32 cmd_type = igb_tx_cmd_type(skb, tx_flags); u16 i = tx_ring->next_to_use; tx_desc = IGB_TX_DESC(tx_ring, i); igb_tx_olinfo_status(tx_ring, tx_desc, tx_flags, skb->len - hdr_len); size = skb_headlen(skb); data_len = skb->data_len; dma = dma_map_single(tx_ring->dev, skb->data, size, DMA_TO_DEVICE);
上面的代码做了几件事:
IGB_TX_DESC
宏确定获取下一个可用描述符的引用。igb_tx_olinfo_status
更新 tx_flags
并复制其到描述符(tx_desc
)中。dma_map_single
构造获得 skb->data
数据的 DMA 可访问地址所需的任何内存映射。 这样做使得设备可以从存储器读取数据包数据。接下来是驱动程序中的一个非常密集的循环,为 skb 的每个片段生成有效的映射。 具体如何发生这种情况的细节并不特别重要,但值得一提:
以下提供循环的代码,以供参考以上描述。 这应该进一步向读者说明,如果可能的话,避免碎片化是一个好主意。 需要在堆栈的每一层运行大量额外的代码来处理它,包括驱动程序。
tx_buffer = first;for (frag = &skb_shinfo(skb)->frags[0];; frag++) { if (dma_mapping_error(tx_ring->dev, dma)) goto dma_error; /* record length, and DMA address */ dma_unmap_len_set(tx_buffer, len, size); dma_unmap_addr_set(tx_buffer, dma, dma); tx_desc->read.buffer_addr = cpu_to_le64(dma); while (unlikely(size > IGB_MAX_DATA_PER_TXD)) { tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type ^ IGB_MAX_DATA_PER_TXD); i++; tx_desc++; if (i == tx_ring->count) { tx_desc = IGB_TX_DESC(tx_ring, 0); i = 0; } tx_desc->read.olinfo_status = 0; dma += IGB_MAX_DATA_PER_TXD; size -= IGB_MAX_DATA_PER_TXD; tx_desc->read.buffer_addr = cpu_to_le64(dma); } if (likely(!data_len)) break; tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type ^ size); i++; tx_desc++; if (i == tx_ring->count) { tx_desc = IGB_TX_DESC(tx_ring, 0); i = 0; } tx_desc->read.olinfo_status = 0; size = skb_frag_size(frag); data_len -= size; dma = skb_frag_dma_map(tx_ring->dev, frag, 0, size, DMA_TO_DEVICE); tx_buffer = &tx_ring->tx_buffer_info[i];}
一旦所有必要的描述符都已构建,并且所有 skb 的数据都已映射到 DMA 地址,驱动程序将继续执行其最后步骤以触发传输:
/* write last descriptor with RS and EOP bits */cmd_type |= size | IGB_TXD_DCMD;tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type);
写入终止描述符以向设备指示它是最后一个描述符。
netdev_tx_sent_queue(txring_txq(tx_ring), first->bytecount);/* set the timestamp */first->time_stamp = jiffies;
调用 netdev_tx_sent_queue
函数时,会添加字节数到此传输队列。 这个函数是字节查询限制特性的一部分,我们稍后会详细介绍。 当前 jiffies 被存储在第一缓冲器信息结构中。
接下来,有一点棘手:
/* Force memory writes to complete before letting h/w know there * are new descriptors to fetch. (Only applicable for weak-ordered * memory model archs, such as IA-64). * * We also need this memory barrier to make certain all of the * status bits have been updated before next_to_watch is written. */wmb();/* set next_to_watch value indicating a packet is present */first->next_to_watch = tx_desc;i++;if (i == tx_ring->count) i = 0;tx_ring->next_to_use = i;writel(i, tx_ring->tail);/* we need this if more than one processor can write to our tail * at a time, it synchronizes IO on IA64/Altix systems */mmiowb();return;
上面的代码正在执行一些重要的操作:
wmb
函数强制完成内存写入。这将作为适用于 CPU 平台的特殊指令执行,通常称为“写屏障”。这在某些 CPU 架构上很重要,因为如果我们在没有确保所有更新内部状态的内存写入都已完成之前触发设备启动 DMA,则设备可能会从 RAM 中读取不一致状态的数据。这篇文章 和这个 讲座 深入探讨了有关内存排序的细节。next_to_watch
字段。它将在完成阶段后使用。next_to_use
字段为下一个可用描述符。writel
函数更新传输队列的尾部。writel
将一个 “long” 写入 内存映射 I/O 地址。在这种情况下,地址是 tx_ring->tail
(这是一个硬件地址),要写入的值是 i
。此写入会触发设备,让它知道有更多数据准备好从 RAM 进行 DMA 并写入网络。mmiowb
函数。此函数将执行适用于 CPU 架构的指令,使内存映射写入操作有序。它也是一个写屏障,但用于内存映射 I/O 写入。如果您想了解更多关于 wmb
、mmiowb
以及何时使用它们,可以阅读 Linux 内核中包含的一些出色的 关于内存屏障的文档。
最后,只有当从 DMA API 返回错误时(当尝试映射 skb 数据地址到可 DMA 地址时),才会执行此代码。
dma_error: dev_err(tx_ring->dev, "TX DMA map failed\n"); /* clear dma mappings for failed tx_buffer_info map */ for (;;) { tx_buffer = &tx_ring->tx_buffer_info[i]; igb_unmap_and_free_tx_resource(tx_ring, tx_buffer); if (tx_buffer == first) break; if (i == 0) i = tx_ring->count; i--; } tx_ring->next_to_use = i;
在继续传输完成之前,让我们检查一下上面传递的内容:动态队列限制。
正如你在这篇文章中看到的那样,随着网络数据越来越靠近传输设备,它会在不同阶段花费大量时间排队。随着队列大小的增加,数据包在未传输的队列中停留的时间更长,即数据包传输延迟随着队列大小增加而增加。
对抗这种情况的一种方法是背压。动态队列限制(DQL)系统是一种机制,设备驱动程序可以使用该机制向网络系统施加背压,
要使用此系统,网络设备驱动程序需要在其传输和完成例程期间进行一些简单的 API 调用。 DQL 系统内部使用一种算法来确定何时有足够的数据传输。 一旦达到此限制,传输队列将暂时禁用。 这种队列禁用是对网络系统产生背压的原因。当DQL系统确定有足够的数据完成传输时,队列将自动重新启用。
查看这组关于 DQL 系统的优秀幻灯片,了解一些性能数据和 DQL 内部算法的解释。
我们刚才看到的代码中调用的函数 netdev_tx_sent_queue
是 DQL API 的一部分。 当数据排队到设备进行传输时,会调用此函数。 传输完成后,驱动程序调用 就会调用 netdev_tx_completed_queue
。 在内部,这两个函数都将调用 DQL 库(位于 ./lib/dynamic_queue_limits.c 和 ./include/linux/dynamic_queue_limits.h 中),以确定传输队列是否应该被禁用、重新启用或保持原样。
DQL 在 sysfs 中导出统计信息和调优旋钮。 调优 DQL 应该是不必要的;该算法将随时间调整其参数。 不过,为了完整起见,我们将在后面看到如何监控和调优 DQL。
一旦设备传输了数据,它将产生一个中断信号,表示传输完成。 然后设备驱动程序可以安排一些长时间运行的工作来完成,比如取消映射内存区域和释放数据。 具体如何工作取决于设备。 在 igb
驱动程序(及其相关设备)的情况下,发射相同的 IRQ 以完成传输和接收数据包。 这意味着对于 igb
驱动程序,NET_RX
处理发送完成和传入数据包接收。
让我重申这一点,以强调其重要性:您的设备可能会在接收数据包时发出与发送数据包完成信号相同的中断。如果是,NET_RX
软中断将运行处理传入数据包和传输完成。
由于两个操作共享同一个 IRQ,因此只能注册一个 IRQ 处理函数,并且它必须处理两种可能的情况。 当接收到网络数据时,调用以下流程:
NET_RX
软中断。igb
驱动程序(和 ixgbe
驱动程序[greetings,tyler])中的上述步骤 5 在处理传入数据之前处理传输完成。 请记住,根据驱动程序的实现,传输完成和传入数据的处理功能可能共享相同的处理预算。 igb
和 ixgbe
驱动器分别跟踪传输完成和传入数据包预算,因此处理传输完成将不一定耗尽传入预算。
也就是说,整个 NAPI 轮询循环在硬编码的时间片内运行。 这意味着,如果要处理大量的传输完成处理,传输完成可能会比处理传入数据占用更多的时间片。 对于那些在非常高的负载环境中运行网络硬件的人来说,这可能是一个重要的考虑因素。
让我们看看 igb
驱动程序在实践中是如何做到这一点的。
这篇文章将不再重复Linux 内核接收端网络博客文章中已经涵盖的信息,而是按顺序列出步骤,并链接到接收端博客文章中的相应部分,直到传输完成。
所以,让我们从头开始:
napi_schedule
来响应 IRQ。NET_RX
软中断执行。NET_RX
软中断函数 net_rx_action
开始执行。net_rx_action
函数调用驱动程序注册的 NAPI 轮询函数。igb_poll
。轮询函数 igb_poll
是代码分离并处理传入数据包和传输完成的地方。 让我们深入研究这个函数的代码,看看它在哪里发生的。
igb_poll
让我们来看看 igb_poll
(来自 ./drivers/net/ethernet/intel/igb/igb_main.c):
/** * igb_poll - NAPI Rx polling callback * @napi: napi polling structure * @budget: count of how many packets we should handle **/static int igb_poll(struct napi_struct *napi, int budget){ struct igb_q_vector *q_vector = container_of(napi, struct igb_q_vector, napi); bool clean_complete = true;#ifdef CONFIG_IGB_DCA if (q_vector->adapter->flags & IGB_FLAG_DCA_ENABLED) igb_update_dca(q_vector);#endif if (q_vector->tx.ring) clean_complete = igb_clean_tx_irq(q_vector); if (q_vector->rx.ring) clean_complete &= igb_clean_rx_irq(q_vector, budget); /* If all work not completed, return budget and keep polling */ if (!clean_complete) return budget; /* If not enough Rx work done, exit the polling mode */ napi_complete(napi); igb_ring_irq_enable(q_vector); return 0;}
此函数执行几个操作,顺序如下:
igb_clean_tx_irq
,执行发送完成操作。igb_clean_rx_irq
,其执行传入数据包处理。clean_complete
以确定是否还有更多的工作可以完成。 如果是,则返回budget
。 如果发生这种情况,net_rx_action
移动这个 NAPI 结构到轮询列表的末尾,以便稍后再次处理。要了解更多关于 igb_clean_rx_irq
工作原理,请阅读上一篇博客文章的这一部分。
这篇博客文章主要关注发送端,所以我们将继续研究上面的 igb_clean_tx_irq
是如何工作的。
igb_clean_tx_irq
请查看 ./drivers/net/ethernet/intel/igb/igb_main.c 中此函数的源代码。
它有点长,所以我们把它分成块并研究它:
static bool igb_clean_tx_irq(struct igb_q_vector *q_vector){ struct igb_adapter *adapter = q_vector->adapter; struct igb_ring *tx_ring = q_vector->tx.ring; struct igb_tx_buffer *tx_buffer; union e1000_adv_tx_desc *tx_desc; unsigned int total_bytes = 0, total_packets = 0; unsigned int budget = q_vector->tx.work_limit; unsigned int i = tx_ring->next_to_clean; if (test_bit(__IGB_DOWN, &adapter->state)) return true;
该函数首先初始化一些有用的变量。 一个重要的考虑因素是 budget
。 正如你在上面看到的budget
被初始化为这个队列的 tx.work_limit
。 在 igb
驱动程序中,tx.work_limit
被初始化为硬编码值 IGB_DEFAULT_TX_WORK
(128)。
值得注意的是,虽然我们现在看到的传输完成代码与接收处理在相同的 NET_RX
软中断中运行,但 TX 和 RX 函数在 igb
驱动程序中并不共享处理预算 。由于整个 poll 函数在相同的时间片内运行,因此单次运行 igb_poll
函数不可能使传入的数据包处理或传输完成饿死。只要调用igb_poll
,两者都会被处理。
接下来,上面的代码片段以检查网络设备是否关闭结束。如果是,则返回 true
并退出igb_clean_tx_irq
。
tx_buffer = &tx_ring->tx_buffer_info[i];tx_desc = IGB_TX_DESC(tx_ring, i);i -= tx_ring->count;
tx_buffer
变量被初始化为位于 tx_ring->next_to_clean
(其本身被初始化为0
)的传输缓冲区信息结构。tx_desc
。i
减少发送队列的大小。 这个值可以调整(正如我们将在调优部分看到的那样),但是被初始化为 IGB_DEFAULT_TXD
(256)。接下来,循环开始。 它包括一些有用的注释,以解释每个步骤中发生的事情:
do { union e1000_adv_tx_desc *eop_desc = tx_buffer->next_to_watch; /* if next_to_watch is not set then there is no work pending */ if (!eop_desc) break; /* prevent any other reads prior to eop_desc */ read_barrier_depends(); /* if DD is not set pending work has not been completed */ if (!(eop_desc->wb.status & cpu_to_le32(E1000_TXD_STAT_DD))) break; /* clear next_to_watch to prevent false hangs */ tx_buffer->next_to_watch = NULL; /* update the statistics for this packet */ total_bytes += tx_buffer->bytecount; total_packets += tx_buffer->gso_segs; /* free the skb */ dev_kfree_skb_any(tx_buffer->skb); /* unmap skb header data */ dma_unmap_single(tx_ring->dev, dma_unmap_addr(tx_buffer, dma), dma_unmap_len(tx_buffer, len), DMA_TO_DEVICE); /* clear tx_buffer data */ tx_buffer->skb = NULL; dma_unmap_len_set(tx_buffer, len, 0);
eop_desc
被设置为缓冲区的 next_to_watch
字段。这是在我们之前看到的传输代码中设置的。eop_desc
(eop = 数据包结束)为 NULL
,则没有工作待处理。read_barrier_depends
函数,该函数将为此 CPU 架构执行适当的 CPU 指令,以防止读取被重新排序到此屏障之前。eop_desc
中检查一个状态位。如果未设置 E1000_TXD_STAT_DD
位,则传输尚未完成,因此从循环中退出。tx_buffer->next_to_watch
。驱动程序中的看门狗定时器将监视此字段以确定传输是否挂起。清除此字段将防止看门狗触发。dma_unmap_single
取消映射 skb 数据区域。tx_buffer->skb
为 NULL
并取消映射 tx_buffer
。接下来,在上面的循环内部开始另一个循环:
/* clear last DMA location and unmap remaining buffers */while (tx_desc != eop_desc) { tx_buffer++; tx_desc++; i++; if (unlikely(!i)) { i -= tx_ring->count; tx_buffer = tx_ring->tx_buffer_info; tx_desc = IGB_TX_DESC(tx_ring, 0); } /* unmap any remaining paged data */ if (dma_unmap_len(tx_buffer, len)) { dma_unmap_page(tx_ring->dev, dma_unmap_addr(tx_buffer, dma), dma_unmap_len(tx_buffer, len), DMA_TO_DEVICE); dma_unmap_len_set(tx_buffer, len, 0); }}
该内部循环将在每个传输描述符上循环,直到 tx_desc
到达 eop_desc
。 这段代码取消映射任何附加描述符引用的数据。
外部循环继续:
/* move us one more past the eop_desc for start of next pkt */ tx_buffer++; tx_desc++; i++; if (unlikely(!i)) { i -= tx_ring->count; tx_buffer = tx_ring->tx_buffer_info; tx_desc = IGB_TX_DESC(tx_ring, 0); } /* issue prefetch for next Tx descriptor */ prefetch(tx_desc); /* update budget accounting */ budget--;} while (likely(budget));
外部循环增加迭代器并减少 budget
值。 检查循环不变量以确定循环是否应继续。
netdev_tx_completed_queue(txring_txq(tx_ring), total_packets, total_bytes);i += tx_ring->count;tx_ring->next_to_clean = i;u64_stats_update_begin(&tx_ring->tx_syncp);tx_ring->tx_stats.bytes += total_bytes;tx_ring->tx_stats.packets += total_packets;u64_stats_update_end(&tx_ring->tx_syncp);q_vector->tx.total_bytes += total_bytes;q_vector->tx.total_packets += total_packets;
此代码:
netdev_tx_completed_queue
,它是上面解释的 DQL API 的一部分。 如果处理了足够的传输完成,这将潜在地重新启用传输队列。代码继续执行,首先检查是否设置了 IGBIGB_RING_FLAG_TX_DETECT_HANG
标志。 看门狗定时器在每次运行定时器回调时设置此标志,以强制执行传输队列的定期检查。 如果该标志现在恰好打开,代码将继续并检查传输队列是否挂起:
if (test_bit(IGB_RING_FLAG_TX_DETECT_HANG, &tx_ring->flags)) { struct e1000_hw *hw = &adapter->hw; /* Detect a transmit hang in hardware, this serializes the * check with the clearing of time_stamp and movement of i */ clear_bit(IGB_RING_FLAG_TX_DETECT_HANG, &tx_ring->flags); if (tx_buffer->next_to_watch && time_after(jiffies, tx_buffer->time_stamp + (adapter->tx_timeout_factor * HZ)) && !(rd32(E1000_STATUS) & E1000_STATUS_TXOFF)) { /* detected Tx unit hang */ dev_err(tx_ring->dev, "Detected Tx Unit Hang\n" " Tx Queue <%d>\n" " TDH <%x>\n" " TDT <%x>\n" " next_to_use <%x>\n" " next_to_clean <%x>\n" "buffer_info[next_to_clean]\n" " time_stamp <%lx>\n" " next_to_watch <%p>\n" " jiffies <%lx>\n" " desc.status <%x>\n", tx_ring->queue_index, rd32(E1000_TDH(tx_ring->reg_idx)), readl(tx_ring->tail), tx_ring->next_to_use, tx_ring->next_to_clean, tx_buffer->time_stamp, tx_buffer->next_to_watch, jiffies, tx_buffer->next_to_watch->wb.status); netif_stop_subqueue(tx_ring->netdev, tx_ring->queue_index); /* we are about to reset, no point in enabling stuff */ return true; }
上面的 if
语句检查:
tx_buffer->next_to_watch
,并且jiffies
大于在传输路径上记录到 tx_buffer
的 time_stamp
,其中添加了超时因子,以及E1000_STATUS_TXOFF
。如果这三个测试都为真,则打印一个错误,表明检测到挂起。使用 netif_stop_subqueue
关闭队列,并返回 true
。
让我们继续阅读代码,看看如果没有传输挂起检查,或者如果有,但没有检测到挂起,会发生什么:
#define TX_WAKE_THRESHOLD (DESC_NEEDED * 2) if (unlikely(total_packets && netif_carrier_ok(tx_ring->netdev) && igb_desc_unused(tx_ring) >= TX_WAKE_THRESHOLD)) { /* Make sure that anybody stopping the queue after this * sees the new next_to_clean. */ smp_mb(); if (__netif_subqueue_stopped(tx_ring->netdev, tx_ring->queue_index) && !(test_bit(__IGB_DOWN, &adapter->state))) { netif_wake_subqueue(tx_ring->netdev, tx_ring->queue_index); u64_stats_update_begin(&tx_ring->tx_syncp); tx_ring->tx_stats.restart_queue++; u64_stats_update_end(&tx_ring->tx_syncp); } } return !!budget;
在上面的代码中,驱动程序重新启动传输队列(如果先前已禁用)。它首先检查是否:
total_packets
非零),并且netif_carrier_ok
以确保设备未被关闭,以及TX_WAKE_THRESHOLD
。在我的 x86_64 系统上,此阈值似乎为 42
。如果所有条件都满足,则使用写屏障(smp_mb
)。接下来检查另一组条件:
然后调用 netif_wake_subqueue
唤醒传输队列并向更高层次发出信号,表示它们可以再次排队数据。增加 restart_queue
统计计数器。接下来我们将看到如何读取此值。
最后,返回一个布尔值。如果有任何剩余的未使用预算,则返回 true
,否则返回 false
。在 igb_poll
中检查此值以确定返回给 net_rx_action
的内容。
igb_poll
返回值igbigb_poll
函数有以下代码来确定返回给 net_rx_action
:
if (q_vector->tx.ring) clean_complete = igb_clean_tx_irq(q_vector);if (q_vector->rx.ring) clean_complete &= igb_clean_rx_irq(q_vector, budget);/* If all work not completed, return budget and keep polling */if (!clean_complete) return budget;
换句话说,如果:
igb_clean_tx_irq
清除了所有传输完成,而没有耗尽其传输完成预算,以及igb_clean_rx_irq
清除了所有传入数据包,而没有耗尽其数据包处理预算然后,将返回整个预算数量(对于大多数驱动程序,它被硬编码为 64
,包括 igb
)。 如果传输或传入处理中的任何一个不能完成(因为还有更多的工作要做),则调用 napi_complete
并返回 0
:
/* If not enough Rx work done, exit the polling mode */ napi_complete(napi); igb_ring_irq_enable(q_vector); return 0;}
有几种不同的方法可以监控网络设备,提供不同级别的粒度和复杂性。 让我们从最细粒度开始,然后转到最细粒度。
ethtool -S
你可以运行以下命令在 Ubuntu 系统上安装 ethtool
:sudo apt-get install ethtool
.
安装后,您可以传递 -S
标志以及需要统计信息的网络设备的名称来访问统计信息。
使用 ethtool -S
监控详细的 NIC 设备统计信息(例如, 传输错误)。
$ sudo ethtool -S eth0NIC statistics: rx_packets: 597028087 tx_packets: 5924278060 rx_bytes: 112643393747 tx_bytes: 990080156714 rx_broadcast: 96 tx_broadcast: 116 rx_multicast: 20294528 ....
监测这些数据可能很困难。 它很容易获得,但字段值没有标准化。 不同的驱动程序,甚至不同版本的同一 驱动可能会产生具有相同含义的不同字段名称。
你应该在标签中寻找带有“drop”、“buffer”、“miss”、“errors”等的值。接下来,您将不得不阅读驱动程序源代码。您将能够确定哪些值完全在软件中计算(例如,在没有内存时增加)以及哪些值直接通过寄存器从硬件读取获得。对于寄存器值,您应该查阅硬件的数据表以确定计数器的真实含义; ethtool
给出的许多标签可能会产生误导。
sysfs 也提供了许多统计值,但它们比直接提供的 NIC 级别统计值略高一些。
您可以使用 cat
在文件上查找丢弃的传入网络数据帧的数量,例如 eth0。
使用 sysfs 监控更高级别的 NIC 统计信息。
$ cat /sys/class/net/eth0/statistics/tx_aborted_errors2
计数器值将被拆分为 tx_aborted_errors
、tx_carrier_errors
、tx_compressed
、tx_dropped
等文件。
不幸的是,由驱动程序来决定每个字段的含义,以及何时增加它们以及值来自何处。 您可能会注意到,一些驱动程序将某种类型的错误情况视为丢弃,但其他驱动程序可能会将其视为未命中。
如果这些值对您很重要,您需要阅读驱动程序源代码和设备数据表,以准确了解驱动程序认为的每个值的含义。
/proc/net/dev
更高级的文件是 /proc/net/dev
,它为系统上的每个网络适配器提供高级摘要式信息。
读取 /proc/net/dev
来监视高级 NIC 统计信息。
$ cat /proc/net/devInter-| Receive | Transmit face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed eth0: 110346752214 597737500 0 2 0 0 0 20963860 990024805984 6066582604 0 0 0 0 0 0 lo: 428349463836 1579868535 0 0 0 0 0 0 428349463836 1579868535 0 0 0 0 0 0
这个文件显示了您在上面提到的 sysfs 文件中找到的值的子集,但它可能作为一个有用的一般参考。
上面提到的警告也适用于这里:如果这些值对您很重要,您仍然需要阅读驱动程序源代码,以准确了解何时、何地以及为什么它们会增加,以确保您对 error、drop 或 fifo 的理解与驱动程序相同。
您可以读取位于以下位置的文件来监控网络设备的动态队列限制:
/sys/class/net/NIC/queues/tx-QUEUE_NUMBER/byte_queue_limits/
。
替换 NIC
为您的设备名称(eth0
、eth1
等),替换 tx-QUEUE_NUMBER
为传输队列号(tx-0
、tx-1
、tx-2
等)。
其中一些文件是:
hold_time
:初始化为 HZ
(单个赫兹)。 如果队列在 hold_time
内已满,则减小最大大小。inflight
:它是尚未处理完成的正在传输的数据包的当前数量。该值等于(排队的数据包数量-完成的数据包数量)。 limit_max
:硬编码值,设置为 DQL_MAX_LIMIT
(在我的 x86_64 系统上为 1879048192
)。limit_min
:硬编码值,设置为 0
。limit
:一个介于 limit_min
和 limit_max
之间的值,表示当前可以排队的对象的最大数量。在修改任何这些值之前,强烈建议阅读这些演示幻灯片,以深入了解算法。
读取 /sys/class/net/eth0/queues/tx-0/byte_queue_limits/inflight
监控在传输过程中的数据包情况。
$ cat /sys/class/net/eth0/queues/tx-0/byte_queue_limits/inflight350
如果您的 NIC 和系统上加载的设备驱动程序支持多个传输队列,则通常可以使用 ethtool 调整 TX 队列(也称为 TX 通道)的数量 ethtool
。
使用 ethtool 检查 NIC 传输队列的数量 ethtool
$ sudo ethtool -l eth0Channel parameters for eth0:Pre-set maximums:RX: 0TX: 0Other: 0Combined: 8Current hardware settings:RX: 0TX: 0Other: 0Combined: 4
此输出显示预设的最大值(由驱动程序和硬件强制执行)和当前设置。
注意: 并非所有设备驱动程序都支持此操作。
如果您的 NIC 不支持此操作,则会出现错误。
$ sudo ethtool -l eth0Channel parameters for eth0:Cannot get device channel parameters: Operation not supported
这意味着您的驱动程序尚未实现 ethtool get_channels
操作。 这可能是因为 NIC 不支持调整队列数量,不支持多个传输队列,或者您的驱动程序尚未更新以处理此功能。
找到当前和最大队列计数后,可以使用 sudo ethtool -L
调整这些值。
注意: 某些设备及其驱动程序仅支持为发送和接收配对的组合队列,如上一节中的示例所示。
使用 ethtool -L
设置组合 NIC 传输和接收队列为 8
$ sudo ethtool -L eth0 combined 8
如果您的设备和驱动程序支持 RX 和 TX 的单独设置,并且您只想更改 TX 队列计数为 8,则可以运行:
使用 ethtool -L
设置 NIC 传输队列的数量为 8。
$ sudo ethtool -L eth0 tx 8
注意: 对于大多数驱动程序来说,进行这些更改将关闭接口,然后再重新打开; 与该接口的连接将被中断。 不过,这对于一次性的改变来说可能并不重要。
某些 NIC 及其驱动程序还支持调整 TX 队列的大小。 具体的工作原理是硬件相关的,但幸运的是,ethtool
为用户提供了一种通用的方法来调整大小。 由于使用了 DQL 来防止更高层次的网络代码在某些时候排队更多数据,因此增加发送队列的大小可能不会产生巨大的差异。尽管如此,您可能仍然希望增加发送队列到最大大小,并让 DQL 为您处理其他所有事情:
使用 ethtool -g
检查当前网卡队列大小。
$ sudo ethtool -g eth0Ring parameters for eth0:Pre-set maximums:RX: 4096RX Mini: 0RX Jumbo: 0TX: 4096Current hardware settings:RX: 512RX Mini: 0RX Jumbo: 0TX: 512
上面的输出指示硬件支持多达 4096 个接收和发送描述符,但是它当前仅使用 512 个。
使用 ethtool -G
增加每个 TX 队列的大小到 4096
$ sudo ethtool -G eth0 tx 4096
注意: 对于大多数驱动程序来说,进行这些更改将关闭接口,然后再重新打开;与该接口的连接将被中断。 不过,这对于一次性的改变来说可能并不重要。
结束了! 现在你已经知道了 Linux 上数据包传输的工作原理:从用户程序到设备驱动程序再返回。
有一些额外的事情值得一提,值得一提的是,似乎不太正确的其他任何地方。
MSG_CONFIRM
)send
、sendto
和 sendmsg
系统调用都采用 flags
参数。 如果您传递 MSG_CONFIRM
标志给应用程序中的这些系统调用,它将导致内核中发送路径上的 dst_neigh_output
函数更新邻居结构的时间戳。 这样做的结果是相邻结构将不会被垃圾收集。 这可以防止产生额外的 ARP 流量,因为邻居缓存条目将保持更热、更长时间。
我们在整个 UDP 协议栈中广泛地研究了 UDP corking。 如果要在应用程序中使用它,可以调用 setsockopt
启用 UDP corking,设置 level 为 IPPROTO_UDP
,optname 设置为 UDP_CORK
,optval
设置为 1
。
正如上面的博客文章中提到的,网络栈可以收集传出数据的时间戳。 请参阅上面的网络栈演练,了解软件中的传输时间戳发生的位置。 一些 NIC 甚至还支持硬件中的时间戳。
如果您想尝试确定内核网络栈在发送数据包时增加了多少延迟,这是一个有用的特性。
关于时间戳的内核文档非常好,甚至还有一个包含的示例程序和 Makefile,你可以查看!
使用 ethtool -T
确定您的驱动程序和设备支持的时间戳模式。
$ sudo ethtool -T eth0Time stamping parameters for eth0:Capabilities: software-transmit (SOF_TIMESTAMPING_TX_SOFTWARE) software-receive (SOF_TIMESTAMPING_RX_SOFTWARE) software-system-clock (SOF_TIMESTAMPING_SOFTWARE)PTP Hardware Clock: noneHardware Transmit Timestamp Modes: noneHardware Receive Filter Modes: none
不幸的是,这个网卡不支持硬件传输时间戳,但是软件时间戳仍然可以在这个系统上使用,以帮助我确定内核给我的数据包传输路径增加了多少延迟。
Linux 网络栈很复杂。
正如我们上面看到的,即使像 NET_RX
这样简单的东西也不能保证像我们期望的那样工作。 即使RX
在名称中,传输完成仍在此 softIRQ 中处理。
这突出了我认为是问题的核心:除非您仔细阅读并理解网络栈的工作原理,否则无法优化和监控网络栈。您无法监控您不深入了解的代码。
原文: Monitoring and Tuning the Linux Networking Stack: Sending Data
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/04-25-2023/monitoring-and-tuning-the-linux-networking-stack-sent-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
本文解释了 Linux 内核的计算机如何接收数据包,以及当数据包从网络流向用户程序时,如何监视和调优网络栈的每个组件。
更新 我们已经发布了本文的姊妹篇:监控和调优 Linux 网络栈:发送数据。
更新 查看 监控和调优 Linux 网络栈图解指南:接收数据,它为下面的内容添加了一些图表。
如果不阅读内核的源代码,不深入了解到底发生了什么,就不可能调优或监控 Linux 网络栈。
希望本文能给想做这方面工作的人提供参考。
特别感谢 Private Internet Access 的工作人员雇用我们,结合其他网络研究进行进一步研究,并慷慨地允许以研究为基础发布这些信息。
本文基于为 Private Internet Access 所做的工作,最初以 5 部分的系列文章的形式发表。
Linux 网络栈是复杂的,没有一刀切的监控或调优解决方案。 如果您真的想调优网络栈,您别无选择,只能投入大量的时间、精力和金钱来了解网络系统的各个部分是如何交互的。
理想情况下,您应该考虑在网络栈的每一层测量数据包丢弃。 这样您就可以确定并缩小需要调优的组件的范围。
这就是我认为许多运营商偏离轨道的地方:假设一组 sysctl 设置或 /proc
值可以简单地被大规模重用。在某些情况下,也许可以,但事实证明,整个系统是如此微妙和交织在一起,如果您希望有意义的监控或调优,您必须努力深入了解系统如何运作。否则,您可以直接使用默认设置,在必要的进一步优化(以及推导这些设置所需的投资)之前,已经足够好。
本文中提供的许多示例设置仅用于说明目的,并不是对某个配置或默认设置的推荐或反对。 在调整任何设置之前,您应该围绕您需要监控的内容制定一个参考框架,以注意到有意义的变化。
通过网络连接到计算机时调整网络设置是危险的;你很容易地把自己锁在外面,或者完全关闭你的网络。 不要在生产机器上调整这些设置;相反,如果可能的话,在新机器上进行调整,再投入生产中。
作为参考,您可能需要手边有一份设备数据手册。 这篇文章将研究由 igb
设备驱动程序控制的 Intel I350 以太网控制器。 您可以找到该数据手册(警告:大型 PDF)供您参考。
数据包从到达到套接字接收缓冲区的流程概览:
ksoftirqd
进程运行在系统的每个 CPU 上。 它们在启动时注册。 ksoftirqd
进程调用设备驱动程序在初始化期间注册的 NAPI poll
函数,从环形缓冲区收取数据包。整个流程将在以下各节中详细介绍。
下面检查的协议层是IP和UDP协议层。 本文提供的许多信息也将作为其他协议层的参考。
本文将探讨 Linux 3.13.0 版本内核,贯穿全文提供了 GitHub 代码链接和代码片段。
准确理解 Linux 内核如何接收数据包是非常复杂的。 我们需要仔细检查和理解网络驱动程序是如何工作的,以便更加清晰理解后面的网络栈部分。
本文将介绍 igb
网络驱动程序。 此驱动程序用于相对常见的服务器 NIC,即 Intel Ethernet Controller I350。 那么,让我们从理解 igb
网络驱动程序的工作原理开始。
驱动程序注册一个初始化函数,当驱动程序被加载时,内核会调用该函数。 此函数使用module_init
宏注册。
igb
初始化函数(igb_init_module
)及其与 module_init
的注册可以在 drivers/net/ethernet/intel/igb/igb_main.c 中找到。
两者都非常简单明了:
/** * igb_init_module - Driver Registration Routine * * igb_init_module is the first routine called when the driver is * loaded. All it does is register with the PCI subsystem. **/static int __init igb_init_module(void){ int ret; pr_info("%s - version %s\n", igb_driver_string, igb_driver_version); pr_info("%s\n", igb_copyright); /* ... */ ret = pci_register_driver(&igb_driver); return ret;}module_init(igb_init_module);
初始化设备的大部分工作都是调用 pci_register_driver
完成的,我们将在下面看到。
英特尔 I350 网卡是一种 PCI express 设备。
PCI 设备通过 PCI 配置空间 中的一系列寄存器标识自己。
当设备驱动程序被编译时,会使用一个名为 MODULE_DEVICE_TABLE
的宏(来自 include/module.h
)来导出一个 PCI 设备 ID 表,标识设备驱动程序可以控制的设备。该表注册为一个结构的一部分,我们稍后将看到。
内核使用此表来确定要加载哪个设备驱动程序来控制设备。
这就是操作系统如何确定哪些设备连接到系统,以及应该使用哪个驱动程序与设备通信。
此表和 igb
驱动程序的 PCI 设备 ID 位于 drivers/net/ethernet/intel/igb/igb_main.c
和 drivers/net/ethernet/intel/igb/e1000_hw.h
:
static DEFINE_PCI_DEVICE_TABLE(igb_pci_tbl) = { { PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_BACKPLANE_1GBPS) }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_SGMII) }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I354_BACKPLANE_2_5GBPS) }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I211_COPPER), board_82575 }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_COPPER), board_82575 }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_FIBER), board_82575 }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SERDES), board_82575 }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SGMII), board_82575 }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_COPPER_FLASHLESS), board_82575 }, { PCI_VDEVICE(INTEL, E1000_DEV_ID_I210_SERDES_FLASHLESS), board_82575 }, /* ... */};MODULE_DEVICE_TABLE(pci, igb_pci_tbl);
如上一节所示,驱动程序的初始化函数会调用 pci_register_driver
。
这个函数注册一个指针结构。 大多数指针是函数指针,但 PCI 设备 ID 表也被注册。 内核使用驱动程序注册的函数启动 PCI 设备。
来自 drivers/net/ethernet/intel/igb/igb_main.c
:
static struct pci_driver igb_driver = { .name = igb_driver_name, .id_table = igb_pci_tbl, .probe = igb_probe, .remove = igb_remove, /* ... */};
一旦通过 PCI ID 识别了设备,内核就可以选择适当的驱动程序来控制该设备。每个 PCI 驱动程序都在内核的 PCI 系统中注册了一个探测函数。内核为尚未被设备驱动程序认领的设备调用此函数。一旦设备被认领,不会再就该设备询问其他驱动程序。大多数驱动程序都有大量的代码运行,以使设备做好使用准备。所做的确切事情因驱动程序而异。
要执行的一些典型操作包括:
struct net_device_ops
结构。此结构包含指向打开设备、发送数据到网络、设置 MAC 地址等各种函数的函数指针。struct net_device
,表示网络设备。让我们快速看一下 igb
驱动程序中 igb_probe
函数的一些操作。
下面的 igb_probe
函数代码执行一些基本的 PCI 配置。 来自drivers/net/ethernet/intel/igb/igb_main.c:
err = pci_enable_device_mem(pdev);/* ... */err = dma_set_mask_and_coherent(&pdev->dev, DMA_BIT_MASK(64));/* ... */err = pci_request_selected_regions(pdev, pci_select_bars(pdev, IORESOURCE_MEM), igb_driver_name);pci_enable_pcie_error_reporting(pdev);pci_set_master(pdev);pci_save_state(pdev);
首先,设备使用 pci_enable_device_mem
进行初始化。这将唤醒设备(如果它处于挂起状态),启用内存资源等。
接下来,将设置 DMA 掩码。此设备可以读写 64 位内存地址,因此使用 DMA_BIT_MASK(64)
调用 dma_set_mask_and_coherent
。
调用 pci_request_selected_regions
保留内存区域,启用 PCI Express 高级错误报告(如果加载了 PCI AER 驱动程序),调用 pci_set_master
启用 DMA,并调用 pci_save_state
保存 PCI 配置空间。
全面解释 PCI 设备如何工作超出了本文的范围,但这个精彩的演讲,这个 wiki和这个来自 Linux 内核的文件都是很好的资源。
igb_probe
函数执行一些重要的网络设备初始化。除了 PCI 特定的工作外,它还执行更多通用的网络和网络设备工作:
struct net_device_ops
。ethtool
操作。net_device
特性标志。让我们逐个来看看,它们很有趣。
struct net_device_ops
struct net_device_ops
包含指向许多重要操作的函数指针,网络子系统需要这些操作来控制设备。在本文的其余部分,我们将多次提到这个结构。
net_device_ops
结构被关联到 igb_probe
中的 struct net_device
上。来自 drivers/net/ethernet/intel/igb/igb_main.c
static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent){ /* ... */ netdev->netdev_ops = &igb_netdev_ops;
并且此 net_device_ops
结构保存的指针指向的函数也在同一个文件中设置。 来自 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, .ndo_get_stats64 = igb_get_stats64, .ndo_set_rx_mode = igb_set_rx_mode, .ndo_set_mac_address = igb_set_mac, .ndo_change_mtu = igb_change_mtu, .ndo_do_ioctl = igb_ioctl, /* ... */
如您所见,该 struct
有几个有趣的字段,如 ndo_open
、ndo_stop
、ndo_start_xmit
和 ndo_get_stats64
,它们保存了 igb
驱动程序实现的函数地址。
稍后我们将更详细地了解其中的一些内容。
ethtool
注册ethtool
是一个命令行程序,您可以使用它来获取和设置各种驱动程序和硬件选项。在 Ubuntu 上,您可以运行 apt-get install ethtool
安装它。
ethtool
的一个常见用途是从网络设备收集详细统计信息。其他有趣的 ethtool
设置将在后面描述。
ethtool
程序使用 ioctl
系统调用与设备驱动程序通信。设备驱动程序注册一系列 ethtool
操作的函数,内核负责粘合。
当从 ethtool
发出 ioctl
调用时,内核找到驱动程序注册的 ethtool
结构,并执行已注册的函数。驱动程序的 ethtool
函数实现可以做任何事情,从更改驱动程序中的简单软件标志到向设备写入寄存器值来调整实际 NIC 硬件的工作方式。
igb
驱动程序调用 igb_set_ethtool_ops
在 igb_probe
中注册其 ethtool
操作:
static int igb_probe(struct pci_dev *pdev, const struct pci_device_id *ent){ /* ... */ igb_set_ethtool_ops(netdev);
igb
驱动程序的 ethtool
代码可以在文件 drivers/net/ethernet/intel/igb/igb_ethtool.c
中找到,同时还有 igb_set_ethtool_ops
函数。
来自 drivers/net/ethernet/intel/igb/igb_ethtool.c
:
void igb_set_ethtool_ops(struct net_device *netdev){ SET_ETHTOOL_OPS(netdev, &igb_ethtool_ops);}
在上面,您可以找到 igb_ethtool_ops
结构,其中 igb
驱动程序支持的 ethtool
函数设置为适当的字段。
来自 drivers/net/ethernet/intel/igb/igb_ethtool.c
:
static const struct ethtool_ops igb_ethtool_ops = { .get_settings = igb_get_settings, .set_settings = igb_set_settings, .get_drvinfo = igb_get_drvinfo, .get_regs_len = igb_get_regs_len, .get_regs = igb_get_regs, /* ... */
各个驱动程序决定哪些 ethtool
函数是相关的,哪些应该实现。不幸的是,并非所有驱动程序都实现了所有 ethtool
函数。
一个有趣的 ethtool
函数是 get_ethtool_stats
,它(如果实现)会产生详细的统计计数器,这些计数器要么在驱动程序中的软件中跟踪,要么通过设备本身跟踪。
下面的监控部分将展示如何使用 ethtool
访问这些详细统计信息。
当数据帧通过 DMA 写入 RAM 时,NIC 如何告诉系统其余部分数据已准备好处理?
传统上,NIC 会生成一个 硬中断请求 (IRQ),指示数据已到达。有三种常见类型的 IRQ:MSI-X、MSI 和legacy IRQ。这些将在稍后提及。当数据通过 DMA 写入 RAM 时,设备生成 IRQ 是很简单的,但如果大量数据帧到达,则会生成大量 IRQ。生成的 IRQ 越多,更高级任务(如用户进程)的 CPU 时间就越少。
新 API (NAPI) 被创建为一种减少网络设备在数据包到达时生成的 IRQ 数量的机制。虽然 NAPI 减少了 IRQ 的数量,但不能完全消除它们。
我们将在后面的部分看到为什么会这样。
NAPI 与传统的收集数据方法在几个重要方面有所不同。NAPI 允许设备驱动程序注册一个 poll
函数,NAPI 子系统将调用该函数来收集数据帧。
在网络设备驱动程序中,NAPI 的预期用法如下:
poll
函数来收集数据包。与传统方法相比,这种收集数据帧的方法减少了开销,因为可以一次处理多个数据帧,而无需处理每个数据帧一次 IRQ。
设备驱动程序实现一个 poll
函数并调用 netif_napi_add
将其注册到 NAPI。当使用 netif_napi_add
注册 NAPI poll
函数时,驱动程序还将指定 weight
。大多数驱动程序硬编码一个为 64
的值。这个值及其含义将在下面更详细地描述。
通常,驱动程序在驱动程序初始化期间注册它们的 NAPI poll
函数。
igb
驱动程序的 NAPI 初始化igb
驱动程序通过一个长调用链来实现:
igb_probe
调用 igb_sw_init
。igb_sw_init
调用 igb_init_interrupt_scheme
。igb_init_interrupt_scheme
调用 igb_alloc_q_vectors
。igb_alloc_q_vectors
调用 igb_alloc_q_vector
。igb_alloc_q_vector
调用 netif_napi_add
。该调用跟踪发生了一些高级的事情:
pci_enable_msix
启用它。igb_alloc_q_vector
。igb_alloc_q_vector
都会调用 netif_napi_add
为该队列注册一个 poll
函数,当调用以收集数据包时,将传递一个 struct napi_struct
实例给 poll
。让我们看一下 igb_alloc_q_vector
,看看如何注册 poll
回调及其私有数据。
来自 drivers/net/ethernet/intel/igb/igb_main.c:
static int igb_alloc_q_vector(struct igb_adapter *adapter, int v_count, int v_idx, int txr_count, int txr_idx, int rxr_count, int rxr_idx){ /* ... */ /* allocate q_vector and rings */ q_vector = kzalloc(size, GFP_KERNEL); if (!q_vector) return -ENOMEM; /* initialize NAPI */ netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64); /* ... */
上面的代码为接收队列分配内存,并注册函数 igb_poll
到 NAPI 子系统。它提供了一个指向与此新创建的接收队列关联的 struct napi_struct
的引用(上面的 &q_vector->napi
)。当 NAPI 子系统调用它来从此接收队列收集数据包时,将传递给 igb_poll
。
当我们探讨数据从驱动程序到网络栈的流动时,这一点很重要。
我们之前看到的 net_device_ops
结构体注册了一组函数,用于启动网络设备、传输数据包、设置 MAC 地址等。
当网络设备启动时(例如,使用 ifconfig eth0 up
),net_device_ops
结构体中的 ndo_open
字段所关联的函数会被调用。
ndo_open
函数通常会执行以下操作:
在 igb
驱动程序的情况下,net_device_ops
结构体中 ndo_open
字段所关联的函数被称为 igb_open
。
现在大多数网卡都使用 DMA 直接将数据写入 RAM,操作系统可以从中获取数据进行处理。大多数网卡用于此目的的数据结构类似于基于循环缓冲区(或环形缓冲区)构建的队列。
为了做到这一点,设备驱动程序必须与操作系统协作,保留一块网卡硬件可以使用的内存区域。一旦保留了这个区域,就会告知硬件其位置,传入的数据将被写入 RAM,在 RAM 中稍后将被网络子系统拾取并处理。
这看起来很简单,但如果数据包速率足够高,以至于单个 CPU 无法正确处理所有传入的数据包呢?数据结构建立在固定长度的内存区域上,因此传入的数据包将被丢弃。
这就是 接收端扩展 (RSS) 或多队列能够改善的点。
有些设备能够同时将传入的数据包写入几个不同的 RAM 区域;每个区域都是一个单独的队列。这允许操作系统从硬件层面开始,使用多个 CPU 并行处理传入的数据。并非所有网卡都支持此功能。
Intel I350 网卡支持多队列。我们可以在 igb
驱动程序中看到这一点。当 igb
驱动程序启动时,它首先调用一个名为 igb_setup_all_rx_resources
的函数。这个函数为每个 接收队列调用另一个函数 igb_setup_rx_resources
,以安排设备将传入数据写入 DMA支持内存。
如果您想了解这是如何工作的,请参阅 Linux 内核的 DMA API HOWTO。
事实证明,可以使用 ethtool
调整接收队列的数量和大小。调整这些值会对处理的帧数与丢弃的帧数产生明显影响。
网卡使用数据包头字段(如源、目的地、端口等)上的哈希函数来确定数据应该发送到哪个接收队列。
有些网卡允许您调整接收队列的权重,因此您可以向特定队列发送更多流量。
少部分网卡允许您调整哈希函数本身。如果您可以调整哈希函数,您可以发送某些流量到特定的接收队列进行处理,甚至在硬件层面丢弃数据包(如果需要)。
我们稍后将看看如何调整这些设置。
当网络设备启动时,驱动程序通常会启用 NAPI。
我们之前看到驱动程序如何向 NAPI 注册 poll
函数,但 NAPI 通常不会在设备启动之前启用。
启用 NAPI 相对简单。调用 napi_enable
翻转 struct napi_struct
的一个位,以指示它现在已启用。如上所述,虽然 NAPI 被启用,但它将处于关闭状态。
在 igb
驱动程序的情况下,当驱动程序加载或使用 ethtool
更改队列计数或大小时,会为每个已初始化的 q_vector
启用 NAPI。
来自 drivers/net/ethernet/intel/igb/igb_main.c:
for (i = 0; i < adapter->num_q_vectors; i++) napi_enable(&(adapter->q_vector[i]->napi));
启用 NAPI 后,下一步是注册中断处理程序。设备可以使用不同的方法来发出中断信号:MSI-X、MSI 和 legacy interrupt。因此,代码因设备而异,具体取决于特定硬件支持的中断方法。
驱动程序必须确定设备支持哪种方法,并注册适当的处理程序函数,在接收到中断时执行。
有些驱动程序,如 igb
驱动程序,会尝试使用每种方法注册中断处理程序,在失败时回退到下一个未测试的方法。
MSI-X 中断是首选方法,特别是对于支持多个接收队列的网卡。这是因为每个接收队列都可以分配自己的硬件中断,然后由特定的 CPU 处理(使用 irqbalance
或修改 /proc/irq/IRQ_NUMBER/smp_affinity
)。我们稍后将看到,处理中断的 CPU 将是处理数据包的 CPU。通过这种方式,从硬件中断层次到网络栈,到达的数据包可以由单独的 CPU 处理。
如果 MSI-X 不可用,MSI 仍然比 legacy interrupt 具有优势。如果设备支持它,驱动程序将使用它。阅读 这个有用的维基页面 了解更多关于 MSI 和 MSI-X 的信息。
在 igb
驱动程序中,函数 igb_msix_ring
、igb_intr_msi
、igb_intr
分别是 MSI-X、MSI 和 legacy interrupt 模式的中断处理程序方法。
您可以在驱动程序中找到尝试每种中断方法的代码drivers/net/ethernet/intel/igb/igb_main.c:
static int igb_request_irq(struct igb_adapter *adapter){ struct net_device *netdev = adapter->netdev; struct pci_dev *pdev = adapter->pdev; int err = 0; if (adapter->msix_entries) { err = igb_request_msix(adapter); if (!err) goto request_done; /* fall back to MSI */ /* ... */ } /* ... */ if (adapter->flags & IGB_FLAG_HAS_MSI) { err = request_irq(pdev->irq, igb_intr_msi, 0, netdev->name, adapter); if (!err) goto request_done; /* fall back to legacy interrupts */ /* ... */ } err = request_irq(pdev->irq, igb_intr, IRQF_SHARED, netdev->name, adapter); if (err) dev_err(&pdev->dev, "Error %d getting interrupt\n", err);request_done: return err;}
如上面的简略代码所示,驱动程序首先尝试使用 igb_request_msix
设置 MSI-X 中断处理程序,在失败时回退到 MSI。接下来,使用 request_irq
注册 igb_intr_msi
,即 MSI 中断处理程序。如果这失败了,驱动程序将回退到传统中断。再次使用 request_irq
注册 legacy interrupt 处理程序 igb_intr
。
这就是 igb
驱动程序如何注册一个函数,在网卡引发中断信号表明数据已到达并准备好处理时执行。
此时,几乎所有东西都已设置好。剩下的只是启用网卡的中断并等待数据到达。启用中断是硬件特定的,但 igb
驱动程序在 __igb_open
中调用名为 igb_irq_enable
的辅助函数来实现。
写入寄存器为此设备启用中断。
static void igb_irq_enable(struct igb_adapter *adapter){ /* ... */ wr32(E1000_IMS, IMS_ENABLE_MASK | E1000_IMS_DRSTA); wr32(E1000_IAM, IMS_ENABLE_MASK | E1000_IMS_DRSTA); /* ... */}
驱动程序可能会做一些其他事情,如启动计时器、工作队列或其他硬件特定的设置。一旦完成,网络设备就已启动并准备好使用。
让我们看看如何监控和调优网络设备驱动程序的设置。
有几种不同的方法可以监控您的网络设备,提供不同程度的粒度和复杂性。让我们从最精细的开始,逐渐过渡到最粗略的。
ethtool -S
使用您可以运行 sudo apt-get install ethtool
在 Ubuntu 系统上安装 ethtool
。
安装完成后,您可以传递 -S
标志以及您想要获取统计信息的网络设备名称来访问统计信息。
使用 ethtool -S
监控详细的网卡设备统计信息(例如,数据包丢弃)。
$ sudo ethtool -S eth0NIC statistics: rx_packets: 597028087 tx_packets: 5924278060 rx_bytes: 112643393747 tx_bytes: 990080156714 rx_broadcast: 96 tx_broadcast: 116 rx_multicast: 20294528 ....
监控这些数据可能很困难。它很容易获得,但字段值没有标准化。不同的驱动程序,甚至不同版本的 相同 驱动程序可能会产生具有相同含义的不同字段名称。
您应该寻找带有 “drop”、“buffer”、“miss” 等标签的值。接下来,您将不得不阅读驱动程序源代码。您能够确定哪些值完全在软件中计算(例如,没有内存时增加),哪些值直接读取寄存器从硬件获得。对于寄存器值,您应该查阅硬件的数据表,以确定计数器的真实含义; ethtool
给出的许多标签都可能是误导性的。
sysfs 也提供了许多统计值,但它们的层级比直接提供的网卡级别统计值略高一些。
您可以使用 cat
在文件上查找丢弃的传入网络数据帧的数量,例如 eth0。
使用 sysfs 监控更高层级的网卡统计信息。
$ cat /sys/class/net/eth0/statistics/rx_dropped2
计数器值分为 collisions
、rx_dropped
、rx_errors
、rx_missed_errors
等文件。
不幸的是,由驱动程序决定每个字段的含义,何时增加它们以及值来自哪里。您可能会注意到,有些驱动程序将某种类型的错误条件计为丢弃,但其他驱动程序可能将其计为错过。
如果这些值对您至关重要,您需要阅读驱动程序源代码,以准确了解您的驱动程序认为每个值的含义。
/proc/net/dev
使用一个更高层级的文件是 /proc/net/dev
,它为系统上的每个网络适配器提供高层级概要信息。
读取 /proc/net/dev
监控高层级网卡统计信息。
$ cat /proc/net/devInter-| Receive | Transmit face | bytes packets errs drop fifo frame compressed multicast | bytes packets errs drop fifo colls carrier compressed eth0: 110346752214 597737500 0 2 0 0 0 20963860 990024805984 6066582604 0 0 0 0 0 0 lo: 428349463836 1579868535 0 0 0 0 0 0 428349463836 1579868535 0 0 0 0 0 0
这个文件显示了上面提到的 sysfs 文件中找到的值的子集,但它可能作为一个有用的一般参考。
上面提到的警告在这里也适用:如果这些值对您很重要,您仍然需要阅读驱动程序源代码,以准确了解何时、何地以及为什么它们会增加,以确保您对 error、drop 或 fifo 的理解与你的驱动程序相同。
如果您的网卡和系统上加载的设备驱动程序支持 RSS / 多队列,您通常可以使用 ethtool
调整接收队列(也称为接收通道)的数量。
使用 ethtool
检查网卡接收队列的数量。
$ sudo ethtool -l eth0Channel parameters for eth0:Pre-set maximums:RX: 0TX: 0Other: 0Combined: 8Current hardware settings:RX: 0TX: 0Other: 0Combined: 4
此输出显示预设的最大值(由驱动程序和硬件强制执行)和当前设置。
注意: 并非所有设备驱动程序都支持此操作。
如果您的 NIC 不支持此操作,则会出现错误。
$ sudo ethtool -l eth0Channel parameters for eth0:Cannot get device channel parameters: Operation not supported
这意味着您的驱动程序没有实现 ethtool 的 get_channels
操作。这可能是因为网卡不支持调整队列数量,不支持 RSS / 多队列,或者您的驱动程序尚未更新以处理此功能。
一旦您找到了当前和最大队列数,您可以使用 sudo ethtool -L
调整这些值。
注意: 一些设备及其驱动程序仅支持组合队列,用于传输和接收,如上一节中的示例。
使用 ethtool -L
设置组合网卡传输和接收队列为 8。
$ sudo ethtool -L eth0 combined 8
如果您的设备和驱动程序支持单独设置接收队列和传输队列,并且您只想更改接收队列数为 8,则可以运行:
使用 ethtool -L
设置 NIC 接收队列数为 8。
$ sudo ethtool -L eth0 rx 8
注意: 对于大多数驱动程序,这些更改将使接口下线,然后重新启动;与此接口的连接将中断。对于一次性更改,这可能并不重要。
一些网卡及其驱动程序也支持调整接收队列的大小。具体如何操作取决于硬件,但幸运的是,ethtool
为用户提供了一种通用的调整大小的方法。在接收大量数据帧的时期,增加接收队列的大小可以帮助防止网卡丢失网络数据。不过,数据仍然可能在软件中丢失,并且需要其他调整来减少或完全消除丢失。
使用 ethtool -g
检查当前网卡队列大小。
$ sudo ethtool -g eth0Ring parameters for eth0:Pre-set maximums:RX: 4096RX Mini: 0RX Jumbo: 0TX: 4096Current hardware settings:RX: 512RX Mini: 0RX Jumbo: 0TX: 512
上述输出表明硬件支持最多 4096 个接收和传输描述符,但目前仅使用 512 个。
使用 ethtool -G
增加每个接收队列的大小到 4096。
$ sudo ethtool -G eth0 rx 4096
注意: 对于大多数驱动程序,这些更改将使接口下线,然后重新启动;与此接口的连接将中断。对于一次性更改,这可能并不重要。
一些网卡支持设置权重来调整网络数据在接收队列之间的分配。
如果满足以下条件,您可以进行配置:
ethtool
函数 get_rxfh_indir_size
和 get_rxfh_indir
。ethtool
版本足够新,支持命令行选项 -x
和 -X
分别显示和设置引导表。使用 ethtool -x
检查 RX 流量引导表。
$ sudo ethtool -x eth0RX flow hash indirection table for eth3 with 2 RX ring(s):0: 0 1 0 1 0 1 0 18: 0 1 0 1 0 1 0 116: 0 1 0 1 0 1 0 124: 0 1 0 1 0 1 0 1
此输出在左侧显示数据包哈希值,其中列出了接收队列 0 和 1。 因此,散列到 2 的数据包将被递送到接收队列 0,而散列到 3 的数据包将被递送到接收队列 1。
示例:在前两个接收队列之间均匀扩散处理
$ sudo ethtool -X eth0 equal 2
如果你想设置自定义权重来改变命中特定接收队列(以及CPU)的数据包数量,你也可以在命令行中指定这些权重:
使用 ethtool -X
设置自定义收队队列权重
$ sudo ethtool -X eth0 weight 6 2
上述命令指定接收队列 0 的权重为 6,接收队列 1 权重为 2,使得推送更多的数据到队列 0 处理。
一些网卡还允许您调整哈希算法中使用的字段,我们接下来会看到。
您可以使用 ethtool
来调整计算 RSS 时使用的哈希字段。
使用 ethtool -n
检查 UDP 接收流哈希所用的字段。
$ sudo ethtool -n eth0 rx-flow-hash udp4UDP over IPV4 flows use these fields for computing Hash flow key:IP SAIP DA
对于 eth0,计算 UDP 流的哈希的字段是 IPv4 源地址和目标地址。让我们添加源端口和目标端口:
使用 ethtool -N
设置 UDP 接收流哈希字段。
$ sudo ethtool -N eth0 rx-flow-hash udp4 sdfn
sdfn
字符串有点神秘;请查看 ethtool
手册页获取每个字母的解释。
调整哈希的字段很有用,但是,对于更精细地控制哪些流将由哪个接收队列处理, ntuple
过滤更有用。
一些网卡支持一种称为 “ntuple 过滤” 的功能。此功能允许用户通过 ethtool
指定一组参数,在硬件中过滤传入的网络数据并将其排队到特定的接收队列。例如,用户可以指定目标为特定端口的 TCP 数据包应发送到接收队列 1。
在英特尔网卡上,此功能通常称为 Intel Ethernet Flow Director。其他网卡供应商可能为此功能提供其他营销名称。
正如我们稍后将看到的,ntuple 过滤是另一种称为加速接收流引导 (aRFS) 的功能的关键组成部分,如果您的网卡支持它,则使用 ntuple 更容易。aRFS 将在后面介绍。
如果系统的运行要求最大化数据局部性,以期在处理网络数据时提高 CPU 缓存命中率,那么此功能可能很有用。例如,考虑在端口 80 上运行的 Web 服务器的以下配置:
如前所述,可以使用 ethtool
配置 ntuple 过滤,但首先,您需要确保在您的设备上启用了此功能。
使用 ethtool -k
检查是否启用了 ntuple 过滤。
$ sudo ethtool -k eth0Offload parameters for eth0:...ntuple-filters: offreceive-hashing: on
正如所见,在这个设备上 ntuple-filters
被禁用。
使用 ethtool -K
启用 ntuple 过滤
$ sudo ethtool -K eth0 ntuple on
一旦你启用了 ntuple 过滤,或者验证它已经启用,你可以使用 ethtool
检查现有的ntuple 规则:
使用 ethtool -u
检查现有的 ntuple 过滤
$ sudo ethtool -u eth040 RX rings availableTotal 0 rules
如您所见,此设备没有 ntuple 过滤规则。您可以在 ethtool
命令行上指定它来添加规则。让我们添加一个规则,定向目标端口为 80 的所有 TCP 流量到接收队列 2:
添加 ntuple 过滤器,发送目标端口为 80 的 TCP 流量到接收队列 2。
$ sudo ethtool -U eth0 flow-type tcp4 dst-port 80 action 2
您还可以使用 ntuple 过滤在硬件级别丢弃特定流的数据包。这对于缓解来自特定 IP 地址的大量传入流量很有用。有关配置 ntuple 过滤规则的更多信息,请参阅 ethtool
手册页。
您通常可以检查 ethtool -S [设备名称]
输出的值来获取有关 ntuple 规则成功(或失败)的统计信息。例如,在英特尔网卡上,统计信息 fdir_match
和 fdir_miss
计算您的 ntuple 过滤规则的匹配和未命中次数。请查阅您的设备驱动程序源代码和设备数据表以追查统计计数器(如果可用)。
在研究网络栈之前,我们需要稍微了解一下 Linux 内核名为软中断的东西。
Linux 内核中的软中断系统是一种在驱动程序中实现的中断处理程序上下文之外执行代码的机制。这个系统很重要,因为在中断处理程序的全部或部分执行期间,硬件中断可能被禁用。中断被禁用的时间越长,错过事件的机会就越大。因此,推迟任何长时间运行的操作到中断处理程序之外是很重要的,以便它能尽快完成并重新启用来自设备的中断。
内核中还有其他机制推迟工作,但对于网络栈,我们将探讨 softirq。
可以将 softirq 系统想象为一系列内核线程(每个 CPU 一个),它们运行已为不同 softirq 事件注册的处理程序函数。如果您曾经查看过 top 并在内核线程列表中看到 ksoftirqd/0
,那么您正在查看在 CPU 0 上运行的 softirq 内核线程。
内核子系统(如网络)可以执行 open_softirq
函数来注册软中断处理程序。我们稍后将看到网络系统如何注册其软中断处理程序。现在,让我们了解更多关于软中断如何工作的信息。
ksoftirqd
既然软中断对于推迟设备驱动程序的工作非常重要,您可能会想象内核生命周期早期就会产生 ksoftirqd
进程,这是正确的。
查看 kernel/softirq.c 中的代码,可以发现 ksoftirqd
系统是如何初始化的。
static struct smp_hotplug_thread softirq_threads = { .store = &ksoftirqd, .thread_should_run = ksoftirqd_should_run, .thread_fn = run_ksoftirqd, .thread_comm = "ksoftirqd/%u",};static __init int spawn_ksoftirqd(void){ register_cpu_notifier(&cpu_nfb); BUG_ON(smpboot_register_percpu_thread(&softirq_threads)); return 0;}early_initcall(spawn_ksoftirqd);
从上面的 struct smp_hotplug_thread
定义中可以看出,注册了两个函数指针:ksoftirqd_should_run
和 run_ksoftirqd
。
作为类似于事件循环的一部分,这两个函数都是从 kernel/smpboot.c 中调用的。
kernel/smpboot.c
中的代码首先调用 ksoftirqd_should_run
,确定是否有未决的软中断,如果有未决的软中断,则执行 run_ksoftirqd
。run_ksoftirqd
在调用 __do_softirq
之前进行了一些小的簿记工作。
__do_softirq
__do_softirq
函数做了一些有趣的事情:
open_softirq
注册)。因此,当您查看 CPU 使用率图表并看到 softirq
或 si
时,您现在知道这是在测量延迟工作上下文中的 CPU 使用量。
/proc/softirqs
softirq
系统增加统计计数器,可以从 /proc/softirqs
读取。监控这些统计数据可以让您了解各种事件的软中断产生的速率。
读取 /proc/softirqs
检查软中断统计信息。
$ cat /proc/softirqs CPU0 CPU1 CPU2 CPU3 HI: 0 0 0 0 TIMER: 2831512516 1337085411 1103326083 1423923272 NET_TX: 15774435 779806 733217 749512 NET_RX: 1671622615 1257853535 2088429526 2674732223 BLOCK: 1800253852 1466177 1791366 634534BLOCK_IOPOLL: 0 0 0 0 TASKLET: 25 0 0 0 SCHED: 2642378225 1711756029 629040543 682215771 HRTIMER: 2547911 2046898 1558136 1521176 RCU: 2056528783 4231862865 3545088730 844379888
这个文件可以让您了解您的网络接收(NET_RX
)处理当前如何分布在您的 CPU 上。如果分布不均匀,您会看到某些 CPU 的计数值比其他 CPU 大。这是一个指示器,表明您可能会从下面描述的 Receive Packet Steering / Receive Flow Steering 中受益。在监控性能时要小心使用这个文件:在网络活动高峰期,您可能会期望看到 NET_RX
增量率增加,但这并不一定是这样。事实证明,这有点微妙,因为网络栈中还有其他调节旋钮会影响 NET_RX
软中断触发的速率,我们很快就会看到。
但是,您应该注意到这一点,以便在调整其他调节旋钮时,您将知道检查 /proc/softirqs
并期望看到变化。
现在,让我们继续探讨网络栈,并从上到下追踪网络数据的接收方式。
现在我们已经了解了网络驱动程序和软中断是如何工作的,让我们看看 Linux 网络设备子系统是如何初始化的。 然后,我们可以从数据包的到达开始跟踪数据包的路径。
网络设备(netdev)子系统在函数 net_dev_init
中初始化。 这个初始化函数中发生了很多有趣的事情。
struct softnet_data
结构初始化net_dev_init
为系统的每个 CPU 创建一组 struct softnet_data
结构。这些结构将保存指向处理网络数据的几个重要内容的指针:
weight
。随着我们在网络栈中向上移动,将更详细地探讨这些点。
net_dev_init
注册一个发送和接收软中断处理程序,它将处理传入或传出的网络数据。 这段代码非常简单:
static int __init net_dev_init(void){ /* ... */ open_softirq(NET_TX_SOFTIRQ, net_tx_action); open_softirq(NET_RX_SOFTIRQ, net_rx_action); /* ... */}
我们很快就会看到驱动程序的中断处理程序如何“引发”(或触发)注册到 NET_RX_SOFTIRQ
软中断的 net_rx_action
函数。
终于,网络数据到达了!
假设接收队列有足够的可用描述符,数据包将通过 DMA 写入 RAM。然后设备引发分配给它的中断(或者在 MSI-X 的情况下,与数据包到达的接收队列绑定的中断)。
通常,当中断被引发时,运行的中断处理程序应该尽量推迟尽可能多的处理到中断上下文之外发生。这至关重要,因为在处理中断时,其他中断可能会被阻塞。
让我们看一下 MSI-X 中断处理程序的源代码;它将真正有助于说明中断处理程序尽可能少地工作的理念。
来自 drivers/net/ethernet/intel/igb/igb_main.c:
static irqreturn_t igb_msix_ring(int irq, void *data){ struct igb_q_vector *q_vector = data; /* Write the ITR value calculated from the previous interrupt. */ igb_write_itr(q_vector); napi_schedule(&q_vector->napi); return IRQ_HANDLED;}
这个中断处理程序非常短,执行 2 个非常快速的操作后返回。
首先,此函数调用 igb_write_itr
,它只更新一个硬件特定的寄存器。在这种情况下,更新的寄存器是跟踪硬件中断到达速率的寄存器。
此寄存器与称为“中断节流”(也称为“中断合并”)的硬件功能结合使用,可控制中断传递到 CPU 的速度。我们很快就会看到 ethtool
如何提供一种调整 IRQ 触发速率的机制。
其次,调用 napi_schedule
,如果 NAPI 处理循环尚未激活,则唤醒它。请注意,NAPI 处理循环在软中断中执行;NAPI 处理循环不从中断处理程序执行。中断处理程序只是使其开始执行(如果尚未执行)。
显示如何工作的实际代码非常重要;它将指导我们了解如何在多 CPU 系统上处理网络数据。
napi_schedule
让我们弄清楚硬件中断处理程序中的 napi_schedule
调用是如何工作的。
请记住,NAPI 的存在是为了在不需要来自 NIC 的中断来信号数据准备好处理的情况下收集网络数据。如前所述,NAPI poll
循环是接收硬件中断引导的。换句话说:NAPI 已启用,但关闭,直到第一个数据包到达时,NIC 引发硬件中断并启动 NAPI。正如我们很快就会看到的那样,还有一些其他情况,其中 NAPI 可能被禁用,并且需要引发硬件中断才能再次启动。
当驱动程序中的中断处理程序调用 napi_schedule
时,将启动 NAPI 轮询循环。napi_schedule
实际上只是一个在头文件中定义的包装函数,它调用 __napi_schedule
。
来自 net/core/dev.c:
/** * __napi_schedule - schedule for receive * @n: entry to schedule * * The entry's receive function will be scheduled to run */void __napi_schedule(struct napi_struct *n){ unsigned long flags; local_irq_save(flags); ____napi_schedule(&__get_cpu_var(softnet_data), n); local_irq_restore(flags);}EXPORT_SYMBOL(__napi_schedule);
这段代码使用 __get_cpu_var
获取注册到当前 CPU 的 softnet_data
结构。这个 softnet_data
结构和从驱动程序传递上来的 struct napi_struct
结构被传递到 ____napi_schedule
。哇,这是很多下划线 ;)
让我们看一下 ____napi_schedule
,来自 net/core/dev.c:
/* Called with irq disabled */static inline void ____napi_schedule(struct softnet_data *sd, struct napi_struct *napi){ list_add_tail(&napi->poll_list, &sd->poll_list); __raise_softirq_irqoff(NET_RX_SOFTIRQ);}
这段代码做了两件重要的事情:
struct napi_struct
,被添加到与当前 CPU 关联的 softnet_data
结构的 poll_list
中。__raise_softirq_irqoff
来“引发”(或触发)NET_RX_SOFTIRQ 软中断。将执行(如果当前未执行)在网络设备子系统初始化期间注册的 net_rx_action
。正如我们很快就会看到的那样,软中断处理函数 net_rx_action
将调用 NAPI poll
函数来收集数据包。
请注意,我们迄今为止看到的所有推迟硬件中断处理程序中的工作到 softirq 的代码,都使用了与当前 CPU 相关联的结构。
虽然驱动程序的硬中断处理程序本身所做的工作非常少,但软中断处理程序在与驱动程序的硬中断处理程序相同的 CPU 上执行。
这就是为什么设置硬中断处理的 CPU 处理很重要:该 CPU 不仅执行驱动程序中的中断处理程序,而且在 NAPI 以软中断方式收集数据包时也将使用相同的 CPU。
正如我们稍后将看到的,像 Receive Packet Steering 的功能可以将一些工作分配给网络栈更高层级的其他 CPU。
注意: 监视硬件中断并不能全面了解数据包处理的健康状况。 许多驱动程序在 NAPI 运行时关闭硬件中断,我们将在后面看到。 它是整个监控解决方案的重要组成部分。
读取 /proc/interrupts
检查硬件中断状态。
$ cat /proc/interrupts CPU0 CPU1 CPU2 CPU3 0: 46 0 0 0 IR-IO-APIC-edge timer 1: 3 0 0 0 IR-IO-APIC-edge i8042 30: 3361234770 0 0 0 IR-IO-APIC-fasteoi aacraid 64: 0 0 0 0 DMAR_MSI-edge dmar0 65: 1 0 0 0 IR-PCI-MSI-edge eth0 66: 863649703 0 0 0 IR-PCI-MSI-edge eth0-TxRx-0 67: 986285573 0 0 0 IR-PCI-MSI-edge eth0-TxRx-1 68: 45 0 0 0 IR-PCI-MSI-edge eth0-TxRx-2 69: 394 0 0 0 IR-PCI-MSI-edge eth0-TxRx-3 NMI: 9729927 4008190 3068645 3375402 Non-maskable interrupts LOC: 2913290785 1585321306 1495872829 1803524526 Local timer interrupts
您可以监控 /proc/interrupts
中的统计信息,以查看随着数据包到达而硬件中断的数量和速率如何变化,并确保您的 NIC 的每个接收队列都由适当的 CPU 处理。正如我们不久将看到的,这个数字只告诉我们发生了多少次硬件中断,但它并不一定是了解接收或处理了多少数据的好指标,因为许多驱动程序会作为与 NAPI 子系统协作的一部分禁用 NIC 硬中断。此外,使用中断合并也会影响从此文件收集的统计信息。监控此文件可以帮助您确定所选的中断合并设置是否真正起作用。
要获得更完整的网络处理健康状况图像,您需要监控 /proc/softirqs
(如上所述)以及我们将在下面介绍的 /proc
中的其他文件。
中断合并 是一种防止设备向 CPU 发出中断的方法,直到有特定数量的工作或事件处于挂起状态。
这可以帮助防止中断风暴,并可以根据使用的设置帮助提高吞吐量或延迟。产生的中断较少会导致吞吐量更高,延迟增加,CPU 使用率降低。产生的中断较多会导致相反的结果:延迟降低,吞吐量降低,但 CPU 使用率增加。
历史上,早期版本的 igb
、e1000
和其他驱动程序都包含对名为 InterruptThrottleRate
的参数的支持。在较新的驱动程序中,此参数已替换为通用的 ethtool
函数。
使用 ethtool -c
获取当前的 IRQ 合并设置。
$ sudo ethtool -c eth0Coalesce parameters for eth0:Adaptive RX: off TX: offstats-block-usecs: 0sample-interval: 0pkt-rate-low: 0pkt-rate-high: 0...
ethtool
提供了一个通用接口,设置各种合并设置。但是,请记住,并非每个设备或驱动程序都支持所有设置。您应该检查驱动程序文档或驱动程序源代码以确定支持或不支持的内容。根据 ethtool 文档:“驱动程序未实现的任何内容都会导致这些值被静默忽略。”
一些驱动程序支持的一个有趣选项是“自适应接收/传输硬中断合并”。此选项通常在硬件中实现。驱动程序通常需要做一些工作来通知 NIC 启用了此功能,并进行一些簿记(如上面的 igb
驱动程序代码所示)。
启用自适应接收/传输硬中断合并的结果是,在数据包速率低时调整中断传递以改善延迟,并在数据包速率高时提高吞吐量。
使用 ethtool -C
启用自适应接收硬中断合并。
$ sudo ethtool -C eth0 adaptive-rx on
你可以使用 ethtool -C
来设置多个选项。一些常见的选项包括:
rx-usecs
:在数据包到达后,延迟多少微秒才触发接收中断。rx-frames
:在触发接收中断之前,最多接收多少个数据帧。rx-usecs-irq
:当主机正在处理中断时,延迟多少微秒才触发接收中断。rx-frames-irq
:当系统正在处理中断时,在触发接收中断之前,最多接收多少个数据帧。还有更多选项。
提醒,你的硬件和驱动程序可能只支持上述选项的一个子集。你应该查阅驱动程序源代码和硬件数据表,以获取有关支持的合并选项的更多信息。
不幸的是,除了头文件之外,你可以设置的选项并没有在其他地方得到很好的记录。查看 include/uapi/linux/ethtool.h 的源代码,以找到 ethtool
支持的每个选项的解释(但不一定是你的驱动程序和 NIC)。
注意:虽然中断合并看起来是一个非常有用的优化,但在尝试优化时,网络栈的其他内部也会受到影响。在某些情况下,中断合并可能很有用,但你应该确保网络栈的其他部分也调整得当。仅仅修改合并设置本身可能带来的好处微不足道。
如果你的网卡支持 RSS/多队列,或者你想优化数据本地性,你可能希望使用特定的 CPU 来处理网卡产生的中断。
设置特定的 CPU 可以让你划分哪些 CPU 处理哪些 IRQ。这些更改可能会影响上层操作,正如在网络栈中看到的那样。
如果你决定调整 IRQ 亲和性,你应该首先检查是否运行了 irqbalance
守护程序。这个守护程序试图自动平衡 IRQ 到 CPU 上,它可能会覆盖你的设置。如果你正在运行 irqbalance
,你应该禁用 irqbalance
或使用 --banirq
与 IRQBALANCE_BANNED_CPUS
结合使用,让 irqbalance
知道它不应该触碰你想要自己分配的 IRQ 和 CPU 集合。
接下来,你应该检查文件 /proc/interrupts
,查看网卡每个网络 RX 队列的 IRQ 编号列表。
最后,你可以修改每个 IRQ 编号的 /proc/irq/IRQ_NUMBER/smp_affinity
来调整每个 IRQ 将由哪些 CPU 处理。
你只需写入十六进制位掩码到此文件,以指示内核应使用哪些 CPU 来处理 IRQ。
示例:设置 IRQ 8 的 IRQ 亲和性为 CPU 0
$ sudo bash -c 'echo 1 > /proc/irq/8/smp_affinity'
当软中断代码确定软中断(译者注:软中断信号)正在等待时,它开始处理并执行 net_rx_action
,网络数据处理就开始了。
让我们来看看 net_rx_action
处理循环的部分内容,以了解它是如何工作的,哪些部分是可调的,以及可以监控什么。
net_rx_action
处理循环net_rx_action
开始从设备通过 DMA 传输数据包到内存中的数据包进行处理。
该函数遍历当前 CPU 队列中的 NAPI 结构列表,对每个结构执行出队操作,并对其进行操作。
处理循环限制了注册的 NAPI poll
函数所能消耗的工作量和执行时间。它通过两种方式实现这一点:
budget
(可以调整),以及来自 net/core/dev.c:
while (!list_empty(&sd->poll_list)) { struct napi_struct *n; int work, weight; /* If softirq window is exhausted then punt. * Allow this to run for 2 jiffies since which will allow * an average latency of 1.5/HZ. */ if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit))) goto softnet_break;
这就是内核如何防止数据包处理占用整个 CPU 的方法。上面提到的 budget
是在这个 CPU 上注册的所有可用 NAPI 结构花费的总预算。
这也是多队列网卡应该仔细调整 IRQ 亲和性的另一个原因。回想一下,处理设备的 IRQ 的 CPU 将是执行软中断处理程序的 CPU,因此也将是上述循环和预算计算运行的 CPU。
具有多个网卡,每个网卡都有多个队列的系统可能会出现多个 NAPI 结构注册到同一个 CPU 的情况。同一 CPU 上所有 NAPI 结构的数据处理都从同一个 budget
中扣减。
如果您没有足够的 CPU 来分布您的网卡的 IRQ,您可以考虑增加 net_rx_action
的 budget
,以允许每个 CPU 处理更多的数据包。增加预算将增加 CPU 使用率(具体来说是 sitime
或 top
或其他程序中的 si
),但减少延迟,因为数据处理更及时。
注意: 无论如何分配预算,CPU 仍然受到 2 个 jiffies 的时间限制。
poll
函数和 权重
回想一下,网络设备驱动程序使用 netif_napi_add
来注册 poll
函数。正如我们在本文前面看到的那样,igb
驱动程序有一段类似这样的代码:
/* initialize NAPI */netif_napi_add(adapter->netdev, &q_vector->napi, igb_poll, 64);
这行代码注册了具有硬编码权重 64 的 NAPI 结构。 现在我们将看到如何在 net_rx_action
处理循环中使用它。
来自 net/core/dev.c:
weight = n->weight;work = 0;if (test_bit(NAPI_STATE_SCHED, &n->state)) { work = n->poll(n, weight); trace_napi_poll(n);}WARN_ON_ONCE(work > weight);budget -= work;
这段代码获取了注册到 NAPI 结构的权重(上面驱动程序代码中的 64
)并传递其给也注册到 NAPI 结构的 poll
函数(上面代码中的 igb_poll
)。
poll
函数返回处理的数据帧数。这个数量被保存为上面的 work
,然后从总 budget
中扣减。
因此,假设:
64
(在 Linux 3.13.0 中,所有驱动程序都使用这个值硬编码),并且budget
为默认值 300
当满足以下任一条件时,您的系统将停止处理数据:
igb_poll
函数(如果没有数据要处理,我们接下来会看到次数更少),或者关于 NAPI 子系统和设备驱动程序之间的契约,有一个重要的信息尚未提及,那就是关闭 NAPI 的要求。
这部分契约如下:
poll
函数消耗了其全部权重(硬编码为 64
),则它不得修改 NAPI 状态。net_rx_action
循环将接管。poll
函数未消耗其全部权重,则必须禁用 NAPI。下次收到 IRQ 并且驱动程序的 IRQ 处理程序调用 napi_schedule
时,NAPI 将重新启用。我们现在将看到 net_rx_action
如何处理该契约的第一部分。接下来,我们检查 poll
函数,我们将看到如何处理该契约的第二部分。
net_rx_action
循环net_rx_action
处理循环以最后一段代码结束,该代码处理前一节中解释的 NAPI 合约的第一部分。 来自 net/core/dev.c:
/* Drivers must not modify the NAPI state if they * consume the entire weight. In such cases this code * still "owns" the NAPI instance and therefore can * move the instance around on the list at-will. */if (unlikely(work == weight)) { if (unlikely(napi_disable_pending(n))) { local_irq_enable(); napi_complete(n); local_irq_disable(); } else { if (n->gro_list) { /* flush too old packets * If HZ < 1000, flush all packets. */ local_irq_enable(); napi_gro_flush(n, HZ >= 1000); local_irq_disable(); } list_move_tail(&n->poll_list, &sd->poll_list); }}
如果整个工作都被消耗了,net_rx_action
会处理两种情况:
ifconfig eth0 down
),这就是包处理循环调用驱动程序的注册 poll
函数处理包的方式。 我们很快就会看到,poll
函数将收集网络数据,并发送其到栈上进行处理。
当以下任一条件满足时,net_rx_action
循环将退出:
!list_empty(&sd->poll_list)
),或这是我们之前看到的代码:
/* If softirq window is exhausted then punt. * Allow this to run for 2 jiffies since which will allow * an average latency of 1.5/HZ. */if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit))) goto softnet_break;
如果跟随 softnet_break
标签,你会偶然发现一些有趣的东西。 来自 net/core/dev.c:
softnet_break: sd->time_squeeze++; __raise_softirq_irqoff(NET_RX_SOFTIRQ); goto out;
struct softnet_data
结构会增加一些统计数据,然后关闭 softirq NET_RX_SOFTIRQ
。time_squeeze
字段是衡量 net_rx_action
有更多工作要做,但预算耗尽或时间限制到达之前无法完成的次数。这是一个极其有用的计数器,理解网络处理中的瓶颈。我们稍后将看到如何监控这个值。禁用 NET_RX_SOFTIRQ
以释放处理时间给其他任务。这是有道理的,因为这段小代码只在有更多工作可以完成时执行,但我们不希望垄断 CPU。
然后执行转移到 out
标签。如果没有更多的 NAPI 结构要处理,执行也可以到达 out
标签,换句话说,预算比网络活动多,所有驱动程序都关闭了 NAPI,而且 net_rx_action
没有剩下任何事情要做。
在从 net_rx_action
返回之前,out
部分做了一件重要的事情:它调用了 net_rps_action_and_irq_enable
。如果启用了 Receive Packet Steering,此函数具有重要作用;它唤醒远程 CPU 开始处理网络数据。
我们稍后将了解更多关于 RPS 的工作原理。现在,让我们看看如何监控 net_rx_action
处理循环的健康状况,并继续深入了解 NAPI poll
函数的内部工作原理,以便我们能够沿着网络栈向上。
在前面的章节中,我们提到设备驱动程序会为设备分配一块内存区域,用于对传入数据包进行 DMA。正如驱动程序负责分配这些区域一样,它也负责取消映射这些区域,收集数据并发送其到网络栈。
让我们看看 igb
驱动程序是如何做到这一点的,以便了解这在实践中是如何运作的。
igb_poll
最后,我们终于可以探讨我们的朋友 igb_poll
。 igb_poll
看起来很简单。 我们来看看。 来自 drivers/net/ethernet/intel/igb/igb_main.c:
/** * igb_poll - NAPI Rx polling callback * @napi: napi polling structure * @budget: count of how many packets we should handle **/static int igb_poll(struct napi_struct *napi, int budget){ struct igb_q_vector *q_vector = container_of(napi, struct igb_q_vector, napi); bool clean_complete = true;#ifdef CONFIG_IGB_DCA if (q_vector->adapter->flags & IGB_FLAG_DCA_ENABLED) igb_update_dca(q_vector);#endif /* ... */ if (q_vector->rx.ring) clean_complete &= igb_clean_rx_irq(q_vector, budget); /* If all work not completed, return budget and keep polling */ if (!clean_complete) return budget; /* If not enough Rx work done, exit the polling mode */ napi_complete(napi); igb_ring_irq_enable(q_vector); return 0;}
这段代码做了一些有趣的事情:
igb_clean_rx_irq
进行繁重的工作,我们接下来会看到。clean_complete
以确定是否还有更多的工作可以完成。如果是这样,返回 budget
(记住,这是硬编码为 64
的)。正如我们前面看到的,net_rx_action
会移动此 NAPI 结构到轮询列表的末尾。napi_complete
关闭 NAPI,并调用 igb_ring_irq_enable
重新启用中断。下一个到达的中断将重新启用 NAPI。让我们看看 igb_clean_rx_irq
如何发送网络数据到栈。
igb_clean_rx_irq
igb_clean_rx_irq
函数是一个循环,每次处理一个数据包,直到用尽 budget
或没有更多数据需要处理为止。
这个函数中的循环做了一些重要的事情:
IGB_RX_BUFFER_WRITE
(16)个额外的缓冲区。skb
结构中。skb
中。如果接收到的数据帧大于缓冲区大小,则需要这样做。skb->len
。csum_error
统计量。如果校验和成功且数据为 UDP 或 TCP 数据,则标记 skb
为 CHECKSUM_UNNECESSARY
。如果校验和失败,则协议栈负责处理此数据包。协议调用 eth_type_trans
计算并存储在 skb
结构中。napi_gro_receive
传递构建的 skb
到网络栈。循环结束后,函数为接收数据包和已处理字节数分配统计计数器。
在继续上行网络栈之前,现在是时候先兵分两路了。首先,让我们看看如何监控和调整网络子系统的 softirqs。接下来,让我们谈谈通用接收卸载 (GRO)。之后,当我们进入 napi_gro_receive
时,网络栈的其余部分将更有意义。
/proc/net/softnet_stat
如前一节所述,在退出 net_rx_action
循环并且可以完成更多工作,但 softirq 的 budget
或时间限制被触发时,net_rx_action
会增加一个统计量。这个统计量作为与 CPU 关联的 struct softnet_data
的一部分进行跟踪。
这些统计数据输出到 proc 中的一个文件:/proc/net/softnet_stat
,不幸的是,关于这个文件的文档非常少。proc 文件中的字段没有标记,并且可能在内核版本之间发生变化。
在 Linux 3.13.0 中,您可以阅读内核源代码来查找哪些值映射到 /proc/net/softnet_stat
中的哪个字段。从 net/core/net-procfs.c:
seq_printf(seq, "%08x %08x %08x %08x %08x %08x %08x %08x %08x %08x %08x\n", sd->processed, sd->dropped, sd->time_squeeze, 0, 0, 0, 0, 0, /* was fastroute */ sd->cpu_collision, sd->received_rps, flow_limit_count);
这些统计数据中包含许多令人困惑的名称,并且在您可能未预期的地方增加。在探讨网络栈时,将提供每个统计数据何时以及在哪里增加的解释。由于在 net_rx_action
中看到了 squeeze_time
统计量,我认为现在记录这个文件是有意义的。
读取 /proc/net/softnet_stat
监控网络数据处理统计信息。
$ cat /proc/net/softnet_stat6dcad223 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 000000006f0e1565 00000000 00000002 00000000 00000000 00000000 00000000 00000000 00000000 00000000660774ec 00000000 00000003 00000000 00000000 00000000 00000000 00000000 00000000 0000000061c99331 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 000000006794b1b3 00000000 00000005 00000000 00000000 00000000 00000000 00000000 00000000 000000006488cb92 00000000 00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000
关于 /proc/net/softnet_stat
的重要细节:
/proc/net/softnet_stat
对应一个 struct softnet_data
结构,每个 CPU 都有一个。sd->processed
,是处理的网络帧数。如果您使用以太网绑定,这可能会超过接收到的网络帧总数。有些情况下,以太网绑定驱动程序会触发网络数据重新处理,同一数据包将使 sd->processed
计数增加不止一次。sd->dropped
,是因处理队列没有空间而丢弃的网络帧数。稍后再谈。sd->time_squeeze
,(如我们所见)是 net_rx_action
循环因消耗预算或达到时间限制而终止的次数,但仍然可以完成更多工作。如前所述,增加 budget
可以帮助减少这种情况。sd->cpu_collision
,是在发送数据包尝试获取设备锁时发生冲突的次数。本文讨论的是接收,因此下面不会看到这个统计量。sd->received_rps
,是唤醒此 CPU 通过 处理器间中断 处理数据包的次数。flow_limit_count
,是达到流量限制的次数。流量限制是可选的 Receive Packet Steering 功能,稍后会探讨到该特性。如果您决定监控此文件并绘制结果图表,则必须非常小心这些字段的顺序是否发生了变化,并且每个字段的含义是否得到了保留。您需要阅读内核源代码来验证这一点。
net_rx_action
预算您可以调整 net_rx_action
预算,设置名为 net.core.netdev_budget
的 sysctl 值来确定注册到 CPU 的所有 NAPI 结构数据包处理可以消耗多少。
示例:设置总体数据包处理预算为 600。
$ sudo sysctl -w net.core.netdev_budget=600
您可能还希望写入此设置到 /etc/sysctl.conf
文件,以便在重启前后保持更改。
Linux 3.13.0上的默认值是 300。
Generic Receive Offloading (GRO) 是 Large Receive Offloading (LRO) 硬件优化的软件实现。
这两种方法的主要思想是,将“足够相似”的数据包组合在一起,减少传递到网络栈的数据包数量,从而减少 CPU 使用率。例如,想象一种情况,正在进行大文件传输,大多数数据包都包含文件中的数据块。与其一次发送一个小数据包到栈,不如将传入的数据包组合成一个具有巨大有效负载的数据包。然后传递该数据包到栈。这样可以让协议层处理单个数据包的头部,同时传递更大的数据块给用户程序。
当然,这种优化的问题是信息丢失。如果一个数据包设置了某些重要选项或标志,则如果该数据包与另一个数据包合并,则该选项或标志可能会丢失。这正是为什么大多数人不使用或鼓励使用 LRO 的原因。一般来说,对于合并数据包,LRO 实现的规则非常宽松。
GRO 作为 LRO 的软件实现被引入,但对于哪些数据包可以合并有更严格的规则。
顺便说一句:如果您曾经使用过 tcpdump
并看到过不切实际的大型传入数据包大小,那么很可能是因为您的系统启用了 GRO。正如您很快就会看到的那样,在 GRO 已经发生之后,数据包抓取被插入栈中。
ethtool
调整 GRO 设置您可以使用 ethtool
检查是否启用了 GRO,也可以调整设置。
使用 ethtool -k
检查您的 GRO 设置。
$ ethtool -k eth0 | grep generic-receive-offloadgeneric-receive-offload: on
如您所见,在这个系统上,我设置 generic-receive-offload
为启用。
使用 ethtool -K
启用(或禁用)GRO。
$ sudo ethtool -K eth0 gro on
注意: 对于大多数驱动程序来说,进行这些更改将使接口关闭,然后再将其重新打开;到该接口的连接将被中断。 不过,这对于一次性的改变来说可能并不重要。
napi_gro_receive
函数 napi_gro_receive
处理 GRO 的网络数据(如果系统启用了GRO),并向上发送数据到协议层。 这个逻辑的大部分是在一个名为 dev_gro_receive
的函数中。
dev_gro_receive
这个函数首先检查是否启用了 GRO,如果是,则准备进行 GRO。在启用 GRO 的情况下,遍历 GRO 卸载过滤器列表,以便高层协议栈对正在考虑进行 GRO 的数据进行操作。这样做是为了使得协议层让网络设备层知道此数据包是否属于当前正在接收卸载的 网络流,并处理应该为 GRO 发生的任何协议相关内容。例如,TCP 协议需要决定是否/何时对正在合并到现有数据包中的数据包进行 ACK。
下面是来自 net/core/dev.c
的代码,它执行此操作:
list_for_each_entry_rcu(ptype, head, list) { if (ptype->type != type || !ptype->callbacks.gro_receive) continue; skb_set_network_header(skb, skb_gro_offset(skb)); skb_reset_mac_len(skb); NAPI_GRO_CB(skb)->same_flow = 0; NAPI_GRO_CB(skb)->flush = 0; NAPI_GRO_CB(skb)->free = 0; pp = ptype->callbacks.gro_receive(&napi->gro_list, skb); break;}
如果协议层指示是时候刷新 GRO 的数据包,则接下来进行处理。 这是调用napi_gro_complete
来实现的,它调用协议层的 gro_complete
回调,然后调用 netif_receive_skb
向上传递数据包到网络栈。
下面是 net/core/dev.c
中的代码,它可以做到这一点:
if (pp) { struct sk_buff *nskb = *pp; *pp = nskb->next; nskb->next = NULL; napi_gro_complete(nskb); napi->gro_count--;}
接下来,如果协议层合并此数据包到现有流中,napi_gro_receive
将简单地返回,因为没有其他事情要做。
如果数据包未合并,并且系统上的 GRO 流少 于MAX_GRO_SKBS
(8),则会向该CPU的NAPI结构上的 gro_list
添加一个新条目。
下面是 net/core/dev.c
中的代码,它可以做到这一点:
if (NAPI_GRO_CB(skb)->flush || napi->gro_count >= MAX_GRO_SKBS) goto normal;napi->gro_count++;NAPI_GRO_CB(skb)->count = 1;NAPI_GRO_CB(skb)->age = jiffies;skb_shinfo(skb)->gso_size = skb_gro_len(skb);skb->next = napi->gro_list;napi->gro_list = skb;ret = GRO_HELD;
这就是 Linux 网络栈中 GRO 系统的工作方式。
napi_skb_finish
一旦 dev_gro_receive
执行完毕,就会调用 napi_skb_finish
,它要么释放不需要的数据结构(因为数据包已经被合并),要么调用 netif_receive_skb
向上传递数据到网络栈(因为已经有 MAX_GRO_SKBS
流被 GRO)。
接下来,是时候让 netif_receive_skb
看看数据是如何传递到协议层的了。 在对此进行探讨之前,我们首先需要了解一下 Receive Packet Steering (RPS)。
回想一下我们之前讨论的网络设备驱动程序注册 NAPI poll
函数的过程。 每个 NAPI
轮询器实例在软中断的上下文中执行,每个 CPU 有一个软中断。 进一步回想一下,驱动程序的 IRQ 处理程序运行的 CPU 将唤醒其 softirq 处理循环来处理数据包。
换句话说:单个 CPU 处理硬件中断并轮询数据包以处理输入数据。
某些 NIC(如Intel I350)在硬件级别支持多个队列。 这意味着传入的数据包可以被 DMA 到每个队列的单独的内存区域,并且还具有单独的 NAPI 结构来管理轮询该区域。 因此,多个 CPU 将处理来自设备的中断,并且还处理数据包。
该特征通常被称为 Receive Side Scaling (RSS)。
Receive Packet Steering (RPS) 是 RSS 的软件实现。 由于它是在软件中实现的,这意味着它可以为任何 NIC 启用,即使是只有单个接收队列的 NIC。 然而,由于它是在软件中,这意味着 RPS 只能在已经从 DMA 存储器区域收取数据包之后进入流。
这意味着您不会注意到处理 IRQ 或 NAPI poll
循环所花费的 CPU 时间减少,但您可以在收集数据包后分布处理数据包的负载,并减少网络栈上的 CPU 时间。
RPS 的工作原理是为传入数据生成一个散列,以确定哪个 CPU 应该处理数据。 然后排队数据到每 CPU 接收网络积压中以进行处理。 处理器间中断(IPI)被传送到拥有积压的 CPU。 如果当前没有处理积压工作中的数据,这有助于启动积压工作处理。 /proc/net/softnet_stat
包含每个 softnet_data
结构体接收 IPI(received_rps
字段)的次数计数。
因此,netif_receive_skb
将继续向网络栈发送网络数据,或者将其移交给 RPS 以在不同的 CPU 上进行处理。
要使 RPS 工作,必须在内核配置中启用它(Ubuntu 内核 3.13.0 是启用的),并使用位掩码描述哪些 CPU 应该处理给定接口和接收队列的数据包。
您可以在内核文档中找到有关这些位掩码的一些文档。
简而言之,要修改的位掩码位于:
/sys/class/net/DEVICE_NAME/queues/QUEUE/rps_cpus
因此,对于 eth0
和 接收队列 0,你将修改 /sys/class/net/eth0/queues/rx-0/rps_cpus
文件,其中十六进制数指示哪些 CPU 应处理来自 eth0
的接收队列 0 的数据包。 正如文档指出的,在某些配置中可能不需要 RPS。
注: 启用 RPS 将数据包处理分配给以前未处理数据包的 CPU,将导致该 CPU 的 NET_RX
软中断数增加,以及 CPU 使用率图中的 si
或 sitime
增加。 您可以比较软中断和 CPU 使用率图表的前后对比,以确认 RPS 配置是否符合您的喜好。
Receive flow steering (RFS) 与 RPS 配合使用。 RPS 尝试在多个 CPU 之间分配传入数据包负载,但不考虑任何数据局部性问题以最大化 CPU 缓存命中率。 您可以使用 RFS 定向同一个流的数据包到同一个 CPU 进行处理,从而帮助提高缓存命中率。
要使 RFS 工作,您必须启用并配置 RPS。
RFS 跟踪所有流的全局哈希表,并且可以设置 net.core.rps_sock_flow_entries
sysctl 来调整该哈希表的大小。
设置 sysctl
增加 RFS 套接字流哈希的大小。
$ sudo sysctl -w net.core.rps_sock_flow_entries=32768
接下来,您还可以设置每个接收队列的流数,方法是写入此值每个接收队列的名为rps_flow_cnt
的 sysfs 文件。
示例:增加 eth0 上接收队列 0 的流数到 2048。
$ sudo bash -c 'echo 2048 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt'
RFS 可以使用硬件加速来加速;NIC 和内核可以一起工作以确定哪些流应该在哪些 CPU 上被处理。 要使用此功能,NIC 和驱动程序必须支持此功能。
请参阅您的网卡数据手册以确定是否支持此功能。 如果您的 NIC 驱动程序公开了一个名为 ndo_rx_flow_steer
的函数,则该驱动程序支持加速 RFS。
假设您的 NIC 和驱动程序支持它,您可以启用和配置一组内容来启用加速 RFS:
CONFIG_RFS_ACCEL
。 Ubuntu kernel 3.13.0 启用ethtool
来验证是否为设备启用了ntuple 支持。一旦配置了上述内容,加速 RFS 自动移动数据到与处理该流数据的CPU核心绑定的接收队列,并且您不需要为每个流手动指定 ntuple 过滤规则。
netif_receive_skb
向上移动网络栈。接着我们上次讲到的 netif_receive_skb
,它从几个地方调用。最常见的两个(也是我们已经看过的两个):
napi_skb_finish
,或者napi_gro_complete
,或者提醒: netif_receive_skb
及其后代在 softirq 处理循环的上下文中运行,使用像 top
这样的工具,您将看到这里花费的时间计入 sitime
或 si
。
netif_receive_skb
首先检查一个 sysctl
值,以确定用户是否在数据包进入积压队列之前或之后请求接收时间戳。如果启用了此设置,则在数据进入 RPS(和 CPU 的关联积压队列)之前对数据进行时间戳。如果禁用了此设置,则在进入队列后进行时间戳。如果启用了 RPS,则可以使用此功能在多个 CPU 之间分配时间戳的负载,但会因此引入一些延迟。
您可以调整一个名为 net.core.netdev_tstamp_prequeue
的 sysctl 来调优接收到数据包后的时间戳:
调整 sysctl
禁用接收数据包的时间戳
$ sudo sysctl -w net.core.netdev_tstamp_prequeue=0
默认值为 1。 请参阅上一节的解释,以了解此设置的确切含义。
netif_receive_skb
处理完时间戳后,netif_receive_skb
的操作方式会因 RPS 是否启用而不同。 让我们先从更简单的路径开始:RPS 已禁用。
如果未启用 RPS,则调用 __netif_receive_skb
,它执行一些簿记工作,然后调用 __netif_receive_skb_core
移动数据到协议栈附近。
我们将看到 __netif_receive_skb_core
的工作原理,但首先让我们看看启用 RPS 的代码路径如何工作,因为该代码也将调用 __netif_receive_skb_core
。
如果启用了 RPS,在处理上述提到的时间戳选项之后,netif_receive_skb
将执行一些计算,以确定应使用哪个 CPU 的积压队列。这是使用 get_rps_cpu
函数完成的。来自 net/core/dev.c:
cpu = get_rps_cpu(skb->dev, skb, &rflow);if (cpu >= 0) { ret = enqueue_to_backlog(skb, cpu, &rflow->last_qtail); rcu_read_unlock(); return ret;}
get_rps_cpu
将考虑上述 RFS 和 aRFS 设置,以确保调用 enqueue_to_backlog
排队数据到所需的 CPU 的 backlog。
enqueue_to_backlog
此函数首先获取指向远程 CPU 的 softnet_data
结构指针,该结构包含指向input_pkt_queue
的指针。 接下来,检查远程 CPU 的 input_pkt_queue
。 来自 net/core/dev.c:
qlen = skb_queue_len(&sd->input_pkt_queue);if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {
首先比较 input_pkt_queue
的长度与 netdev_max_backlog
。如果队列长度大于此值,则丢弃数据。同样,检查流量限制,如果超过了流量限制,则丢弃数据。在这两种情况下,都会增加 softnet_data
结构的丢弃计数。请注意,这是数据将要排队到的 CPU 的 softnet_data
结构。阅读上面关于 /proc/net/softnet_stat
的部分,基于监控的目的了解如何获取丢弃计数。
enqueue_to_backlog
在很少地方调用。它用于已启用 RPS 的数据包处理,也从 netif_rx
调用。大多数驱动程序都 不应 使用 netif_rx
,而应使用 netif_receive_skb
。如果您没有使用 RPS 并且您的驱动程序没有使用 netif_rx
,则增加积压不会对您的系统产生任何明显影响,因为它不会被使用。
注意:您需要检查正在使用的驱动程序。如果它调用了 netif_receive_skb
并且您 没有 使用 RPS,则增加 netdev_max_backlog
将不会产生任何性能改进,因为没有任何数据会进入 input_pkt_queue
。
假设 input_pkt_queue
足够小且未达到流量限制(接下来会详细介绍),则可以排队数据。这里的逻辑有点奇怪,但可以总结为:
____napi_schedule
启动 NAPI 处理循环。继续排队数据。这段代码使用了 goto
,所以要仔细阅读。 来自 net/core/dev.c:
if (skb_queue_len(&sd->input_pkt_queue)) {enqueue: __skb_queue_tail(&sd->input_pkt_queue, skb); input_queue_tail_incr_save(sd, qtail); rps_unlock(sd); local_irq_restore(flags); return NET_RX_SUCCESS; } /* Schedule NAPI for backlog device * We can use non atomic operation since we own the queue lock */ if (!__test_and_set_bit(NAPI_STATE_SCHED, &sd->backlog.state)) { if (!rps_ipi_queued(sd)) ____napi_schedule(sd, &sd->backlog); } goto enqueue;
RPS 可以在多个 CPU 之间分配数据包处理负载,但是单个大流量可能会垄断 CPU 处理时间并使较小的流量饥饿。流量限制是一种功能,限制每个流量排队到积压的数据包数量为一定数量。这有助于确保即使大流量推送数据包,也能处理较小的流量。
上面来自 net/core/dev.c 的 if 语句调用 skb_flow_limit
检查流量限制:
if (qlen <= netdev_max_backlog && !skb_flow_limit(skb, qlen)) {
这段代码检查队列中是否还有空间,以及是否尚未达到流量限制。 默认情况下,禁用流量限制。 要启用流量限制,必须指定位图(类似于 RPS 的位图)。
input_pkt_queue
已满或流量限制导致的丢弃请参阅上面有关监视/proc/net/softnet_stat
。 dropped
字段是一个计数器,每次数据被丢弃而不是排队到CPU的 input_pkt_queue
时,它都会递增。
netdev_max_backlog
防止丢弃在调整此调优值之前,请参阅上一节中的注释。
如果使用 RPS 或驱动程序调用 netif_rx
,则可以增加 netdev_max_backlog
来帮助防止 enqueue_to_backlog
的丢弃。
示例:使用 sysctl
增加 backlog 到 3000。
$ sudo sysctl -w net.core.netdev_max_backlog=3000
默认值为 1000。
poll
权重您可以设置 net.core.dev_weight
sysctl 来调整积压的 NAPI 轮询器的权重。调整此值可以确定积压 poll
循环可以消耗的总预算的多少(请参阅上面调整 net.core.netdev_budget
的部分):
示例:使用 sysctl
增加 NAPI poll
积压处理循环。
$ sudo sysctl -w net.core.dev_weight=600
默认值为 64。
记住,backlog 处理运行在 softirq 上下文,类似于设备驱动程序注册的 poll
函数,并且将受到总 budget
和时间的限制,如前几节所述。
使用 sysctl
设置流量限制表的大小。
$ sudo sysctl -w net.core.flow_limit_table_len=8192
默认值为 4096。
此更改仅影响新分配的流哈希表。 因此,如果您想增加表的大小,应该在启用流量限制之前进行。
要启用 流量限制,您应该在 /proc/sys/net/core/flow_limit_cpu_bitmap
中指定一个位掩码,该位掩码类似于 RPS 位掩码,指示哪些 CPU 启用了流量限制。
每个 CPU 的 backlog 队列插入 NAPI 的方式与设备驱动程序相同。提供了一个 poll
函数,从 softirq 上下文处理数据包。就像设备驱动程序一样,还提供了一个 weight
。
这个 NAPI 结构在初始化网络系统时提供。来自 net/core/dev.c
中的 net_dev_init
:
sd->backlog.poll = process_backlog;sd->backlog.weight = weight_p;sd->backlog.gro_list = NULL;sd->backlog.gro_count = 0;
backlog NAPI 结构与设备驱动程序 NAPI 结构的不同之处在于 weight
参数是可调整的,其中驱动程序编码其 NAPI 权重硬为 64。 我们将在下面的调优部分看到如何使用 sysctl
调整权重。
process_backlog
process_backlog
函数是一个循环,直到其权重(如前一节所述)被消耗完或 backlog 中没有更多数据为止。
backlog 队列中的每个数据都从 backlog 队列中移除,并传递给 __netif_receive_skb
。一旦数据进入 __netif_receive_skb
,代码路径与上面解释的 RPS 禁用情况相同。也就是说,在调用 __netif_receive_skb_core
传递网络数据到协议层之前,__netif_receive_skb
会进行一些簿记工作。
process_backlog
遵循与设备驱动程序相同的 NAPI 契约,即:如果不使用总权重,则禁用 NAPI。通过上面描述的 enqueue_to_backlog
中对 ____napi_schedule
的调用,轮询器重新启动。
该函数返回完成的工作量,net_rx_action
(上面描述)将从预算中扣减该工作量(使用上面描述的 net.core.netdev_budget
进行调整)。
__netif_receive_skb_core
传送数据到数据包抓取和协议层__netif_receive_skb_core
执行传递数据到协议栈的繁重工作。 在此之前,它检查是否安装了捕获传入数据包的数据包抓取。 AF_PACKET
地址族就是一个这样的例子,它通常通过 libpcap库使用。
如果存在这样的抓取,则首先传送数据到那里,然后传送到下一个协议层。
如果安装了一个数据包抓取(通常通过 libpcap),数据包将通过来自 net/core/dev.c 的代码发送到那里:
list_for_each_entry_rcu(ptype, &ptype_all, list) { if (!ptype->dev || ptype->dev == skb->dev) { if (pt_prev) ret = deliver_skb(skb, pt_prev, orig_dev); pt_prev = ptype; }}
如果你对数据如何通过 pcap 的路径感到好奇,请阅读 net/packet/af_packet.c。
一旦满足抓取,__netif_receive_skb_core
发送数据到协议层。它从数据中获取协议字段并遍历为该协议类型注册的传递函数列表来实现这一点。
这可以在 net/core/dev.c 中的 __netif_receive_skb_core
中看到:
type = skb->protocol;list_for_each_entry_rcu(ptype, &ptype_base[ntohs(type) & PTYPE_HASH_MASK], list) { if (ptype->type == type && (ptype->dev == null_or_dev || ptype->dev == skb->dev || ptype->dev == orig_dev)) { if (pt_prev) ret = deliver_skb(skb, pt_prev, orig_dev); pt_prev = ptype; }}
上面的 ptype_base
标识符被定义为 net/core/dev.c 中链表组成的散列表:
struct list_head ptype_base[PTYPE_HASH_SIZE] __read_mostly;
每个协议层在哈希表中的给定槽处向链表添加过滤器,使用称为 ptype_head
的辅助函数计算:
static inline struct list_head *ptype_head(const struct packet_type *pt){ if (pt->type == htons(ETH_P_ALL)) return &ptype_all; else return &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];}
调用 dev_add_pack
向链表中添加筛选器。 这就是协议层如何为它们的协议类型的网络数据传送,注册它们自己的。
现在您知道了网络数据是如何从 NIC 传输到协议层的。
现在我们知道了数据是如何从网络设备子系统传递到协议栈的,让我们看看协议层是如何注册自己的。
本文将探讨 IP 协议栈,因为它是一种常用的协议,并且与大多数读者相关。
IP 协议层将自身插入 ptype_base
哈希表,以便从前面部分描述的网络设备层传递数据到它。
这发生在 net/ipv4/af_inet.c 的 inet_init
函数中:
dev_add_pack(&ip_packet_type);
这将注册在 net/ipv4/af_inet.c 中定义的 IP 数据包类型结构:
static struct packet_type ip_packet_type __read_mostly = { .type = cpu_to_be16(ETH_P_IP), .func = ip_rcv,};
__netif_receive_skb_core
调用 deliver_skb
(如上一节所示),deliver_skb 调用func
(在本例中为 ip_rcv
)。
ip_rcv
从高层次来看,ip_rcv
函数非常简单。有几个完整性检查以确保数据有效。统计计数器也会增加。
ip_rcv
通过 netfilter 传递数据包给 ip_rcv_finish
结束。这样做是为了让任何应该在 IP 协议层匹配的 iptables 规则在数据包继续之前查看数据包。
我们可以在 net/ipv4/ip_input.c 中的 ip_rcv
结尾处看到将数据交给 netfilter 的代码:
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, skb, dev, NULL, ip_rcv_finish);
为了简洁(和我的 RSI),我决定跳过对 netfilter、iptables 和 conntrack 的深入研究。
简而言之,NF_HOOK_THRESH
会检查是否安装了过滤器,并尝试返回执行到 IP 协议层,以避免深入到 netfilter 和 iptables 和 conntrack 等下面的任何钩子。
请记住:如果您有许多或非常复杂的 netfilter 或 iptables 规则,那么这些规则将在 softirq 上下文中执行,并可能产生网络堆栈中的延迟。不过,如果您需要安装特定的规则集,这可能是不可避免的。
ip_rcv_finish
一旦 net filter 有机会查看数据并决定如何处理它,就会调用 ip_rcv_finish
。 当然,只有当数据没有被 netfilter 丢弃时才会发生这种情况。
ip_rcv_finish
以一个优化开始。为了传递数据包到适当的位置,来自路由系统的dst_entry
需要到位。 为了获得一个 dst_entry
,代码最初尝试从该数据的目的地的更高级别协议调用 early_demux
函数。
early_demux
流程是一种优化,它试图检查 dst_entry
是否缓存在套接字结构上,来找到传递数据包所需的 dst_entry
。
下面是 net/ipv4/ip_input.c 中的内容:
if (sysctl_ip_early_demux && !skb_dst(skb) && skb->sk == NULL) { const struct net_protocol *ipprot; int protocol = iph->protocol; ipprot = rcu_dereference(inet_protos[protocol]); if (ipprot && ipprot->early_demux) { ipprot->early_demux(skb); /* must reload iph, skb->head might have changed */ iph = ip_hdr(skb); }}
如您所见,上述代码受到 sysctl_ip_early_demux
的保护。默认情况下,early_demux
是启用的。下一节将介绍如何禁用它以及为什么要禁用它。
如果启用了优化并且没有缓存条目(因为这是第一个到达的数据包),则移交该数据包给内核中的路由系统,在那里将计算并分配 dst_entry
。
路由层完成后,更新统计计数器,并调用 dst_input(skb)
结束函数,该函数又调用了由路由系统关联的数据包的 dst_entry
结构上的输入函数指针。
如果数据包的最终目的地是本地系统,则路由系统将在数据包的 dst_entry
结构上的输入函数指针中关联 ip_local_deliver
函数。
设置 sysctl
禁用 early_demux
优化。
$ sudo sysctl -w net.ipv4.ip_early_demux=0
默认值为1;启用 early_demux
。
添加此 sysctl 是因为一些用户发现在某些情况下使用 early_demux
优化会使吞吐量降低约 5%。
ip_local_deliver
回想一下在 IP 协议层中看到的以下模式:
ip_rcv
做一些初始簿记。ip_rcv_finish
是该回调函数,它完成了数据包的处理,并继续推送数据包到网络栈。ip_local_deliver
具有相同的模式。 来自 net/ipv4/ip_input.c:
/* * Deliver IP Packets to the higher protocol layers. */int ip_local_deliver(struct sk_buff *skb){ /* * Reassemble IP fragments. */ if (ip_is_fragment(ip_hdr(skb))) { if (ip_defrag(skb, IP_DEFRAG_LOCAL_DELIVER)) return 0; } return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN, skb, skb->dev, NULL, ip_local_deliver_finish);}
假设数据没有首先被 netfilter 丢弃,一旦 netfilter 有机会查看数据,将调用 ip_local_deliver_finish
。
ip_local_deliver_finish
ip_local_deliver_finish
从数据包中获取协议,查找为该协议注册的 net_protocol
结构,并调用 net_protocol
结构中 handler
指向的函数。
这向上传递数据包到更高级别的协议层。
读取 /proc/net/snmp
监控详细的 IP 协议统计信息。
$ cat /proc/net/snmpIp: Forwarding DefaultTTL InReceives InHdrErrors InAddrErrors ForwDatagrams InUnknownProtos InDiscards InDelivers OutRequests OutDiscards OutNoRoutes ReasmTimeout ReasmReqds ReasmOKs ReasmFails FragOKs FragFails FragCreatesIp: 1 64 25922988125 0 0 15771700 0 0 25898327616 22789396404 12987882 51 1 10129840 2196520 1 0 0 0...
此文件包含多个协议层的统计信息。 首先显示 IP 协议层。第一行包含空格分隔的名称,每个名称对应下一行中的相应值。
在 IP 协议层中,您会发现统计计数器正在增加。计数器引用 C 枚举类型。 /proc/net/snmp
所有有效的枚举值和它们对应的字段名称可以在 include/uapi/linux/snmp.h 中找到:
enum{ IPSTATS_MIB_NUM = 0,/* frequently written fields in fast path, kept in same cache line */ IPSTATS_MIB_INPKTS, /* InReceives */ IPSTATS_MIB_INOCTETS, /* InOctets */ IPSTATS_MIB_INDELIVERS, /* InDelivers */ IPSTATS_MIB_OUTFORWDATAGRAMS, /* OutForwDatagrams */ IPSTATS_MIB_OUTPKTS, /* OutRequests */ IPSTATS_MIB_OUTOCTETS, /* OutOctets */ /* ... */
读取 /proc/net/netstat
监控扩展 IP 协议统计信息。
$ cat /proc/net/netstat | grep IpExtIpExt: InNoRoutes InTruncatedPkts InMcastPkts OutMcastPkts InBcastPkts OutBcastPkts InOctets OutOctets InMcastOctets OutMcastOctets InBcastOctets OutBcastOctets InCsumErrors InNoECTPkts InECT0Pktsu InCEPktsIpExt: 0 0 0 0 277959 0 14568040307695 32991309088496 0 0 58649349 0 0 0 0 0
格式类似于 /proc/net/snmp
,不同之处在于行的前缀是 IpExt
。
一些有趣的统计数据:
InReceives
:到达 ip_rcv
的 IP 数据包总数,未进行任何数据完整性检查。InHdrErrors
:头部损坏的 IP 数据包总数。头部过短、过长、不存在、IP 协议版本号错误等。InAddrErrors
:主机不可达的 IP 数据包总数。ForwDatagrams
:已转发的 IP 数据包总数。InUnknownProtos
:头部中指定了未知或不支持协议的 IP 数据包总数。InDiscards
:由于内存分配失败而丢弃的 IP 数据包或校验和失败修剪的数据包总数。InDelivers
:成功传递到更高协议层的 IP 数据包总数。请注意,即使 IP 层没有丢弃数据,更高协议层也可能丢弃数据。InCsumErrors
:校验和错误的 IP 数据包总数。请注意,这些值都是在 IP 层的特定位置增加的。代码有时会移动,可能会出现双重计数错误或其他统计错误。如果这些统计数据对您很重要,强烈建议您阅读 IP 协议层源代码,了解您重要的指标何时增加(或不增加)。
本文将研究 UDP,但 TCP 协议处理程序的注册方式和时间与 UDP 协议处理程序相同。
在 net/ipv4/af_inet.c
中,可以找到包含将 UDP、TCP 和 ICMP 协议连接到 IP 协议层的处理程序函数的结构定义。来自 net/ipv4/af_inet.c:
static const struct net_protocol tcp_protocol = { .early_demux = tcp_v4_early_demux, .handler = tcp_v4_rcv, .err_handler = tcp_v4_err, .no_policy = 1, .netns_ok = 1,};static const struct net_protocol udp_protocol = { .early_demux = udp_v4_early_demux, .handler = udp_rcv, .err_handler = udp_err, .no_policy = 1, .netns_ok = 1,};static const struct net_protocol icmp_protocol = { .handler = icmp_rcv, .err_handler = icmp_err, .no_policy = 1, .netns_ok = 1,};
这些结构在 inet 地址族的初始化代码中注册。 来自 net/ipv4/af_inet.c:
/* * Add all the base protocols. */if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0) pr_crit("%s: Cannot add ICMP protocol\n", __func__);if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0) pr_crit("%s: Cannot add UDP protocol\n", __func__);if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0) pr_crit("%s: Cannot add TCP protocol\n", __func__);
我们将研究 UDP 协议层。 如上所述,UDP 的 handler
函数称为 udp_rcv
。
IP 层在此处理数据,这是进入 UDP 层的入口点。 让我们继续旅程。
UDP 协议层的代码可以在以下文件中找到:net/ipv4/udp. c.
udp_rcv
udp_rcv
函数的代码只有一行,它直接调用 __udp4_lib_rcv
来接收数据报。
__udp4_lib_rcv
__udp4_lib_rcv
函数检查以确保数据包有效,并获取 UDP 报头、UDP 数据报长度、源地址和目标地址。 接下来,是一些附加的完整性检查和校验和验证。
回想一下,在前面的 IP 协议层,我们看到在将数据包交到上层协议(在我们的情况下是 UDP)之前执行了一个优化,以附加 dst_entry
到数据包。
如果找到一个套接字和相应的 dst_entry
,__udp4_lib_rcv
将把数据包排队到套接字:
sk = skb_steal_sock(skb);if (sk) { struct dst_entry *dst = skb_dst(skb); int ret; if (unlikely(sk->sk_rx_dst != dst)) udp_sk_rx_dst_set(sk, dst); ret = udp_queue_rcv_skb(sk, skb); sock_put(sk); /* a return value > 0 means to resubmit the input, but * it wants the return to be -protocol, or 0 */ if (ret > 0) return -ret; return 0;} else {
如果 early_demux 操作没有附加套接字,则现在将调用 __udp4_lib_lookup_skb
来查找接收套接字 。
在上述两种情况下,数据报将排队到套接字:
ret = udp_queue_rcv_skb(sk, skb);sock_put(sk);
如果没有找到套接字,则丢弃数据报:
/* No socket. Drop packet silently, if checksum is wrong */if (udp_lib_checksum_complete(skb)) goto csum_error;UDP_INC_STATS_BH(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);/* * Hmm. We got an UDP packet to a port to which we * don't wanna listen. Ignore it. */kfree_skb(skb);return 0;
udp_queue_rcv_skb
此函数的初始部分如下所示:
最后,我们到达接收队列逻辑,它首先检查套接字的接收队列是否已满。 来自 net/ipv4/udp.c
:
if (sk_rcvqueues_full(sk, skb, sk->sk_rcvbuf)) goto drop;
sk_rcvqueues_full
sk_rcvqueues_full
函数检查套接字的 backlog 长度和套接字的 sk_rmem_alloc
,以确定总和是否大于套接字的 sk_rcvbuf
(sk->sk_rcvbuf
):
/* * Take into account size of receive queue and backlog queue * Do not take into account this skb truesize, * to allow even a single big packet to come. */static inline bool sk_rcvqueues_full(const struct sock *sk, const struct sk_buff *skb, unsigned int limit){ unsigned int qsize = sk->sk_backlog.len + atomic_read(&sk->sk_rmem_alloc); return qsize > limit;}
调优这些值有点棘手,因为有很多东西可以调整。
sksk->sk_rcvbuf
(在上面的sk_rcvqueues_full
中称为limit)值可以增加到 sysctlnet.core.rmem_max
设置的值。
设置 sysctl
增加最大接收缓冲区大小。
$ sudo sysctl -w net.core.rmem_max=8388608
sk->sk_rcvbuf
从 net.core.rmem_default
值开始,也可以设置 sysctl 来调整,如下所示:
设置 sysctl
来调整默认的初始接收缓冲区大小。
$ sudo sysctl -w net.core.rmem_default=8388608
您可以在应用程序中调用 setsockopt
并传递 SO_RCVBUF
来设置 sk->sk_rcvbuf
的大小。您可以使用 setsockopt
设置的最大值为 net.core.rmem_max
。
但是,您可以调用 setsockopt
并传递 SO_RCVBUFFORCE
来覆盖 net.core.rmem_max
的限制,但运行应用程序的用户需要具有 CAP_NET_ADMIN
权限。
当调用 skb_set_owner_r
设置数据报的所有者套接字时,会增加 sk->sk_rmem_alloc
的值。我们稍后将在 UDP 层中看到这个调用。
当调用 sk_add_backlog
时,会增加 sk->sk_backlog.len
的值,我们接下来将看到这个调用。
udp_queue_rcv_skb
一旦验证队列未满,则可以继续对数据报进行排队。 来自 net/ipv4/udp.c:
bh_lock_sock(sk);if (!sock_owned_by_user(sk)) rc = __udp_queue_rcv_skb(sk, skb);else if (sk_add_backlog(sk, skb, sk->sk_rcvbuf)) { bh_unlock_sock(sk); goto drop;}bh_unlock_sock(sk);return rc;
第一步是确定套接字当前是否有任何来自用户空间程序的系统调用。 如果没有,则可以调用 __udp_queue_rcv_skb
添加数据报到接收队列。 如果是,则调用 sk_add_backlog
排队数据报到 backlog。
当套接字系统调用调用内核中的 release_sock
释放套接字时,backlog 上的数据报被添加到接收队列。
__udp_queue_rcv_skb
__udp_queue_rcv_skb
函数调用 sock_queue_rcv_skb
添加数据报到接收队列,如果数据报无法添加到套接字的接收队列,则会增加统计计数器。
来自 net/ipv4/udp.c:
rc = sock_queue_rcv_skb(sk, skb);if (rc < 0) { int is_udplite = IS_UDPLITE(sk); /* Note that an ENOMEM error is charged twice */ if (rc == -ENOMEM) UDP_INC_STATS_BH(sock_net(sk), UDP_MIB_RCVBUFERRORS,is_udplite); UDP_INC_STATS_BH(sock_net(sk), UDP_MIB_INERRORS, is_udplite); kfree_skb(skb); trace_udp_fail_queue_rcv_skb(rc, sk); return -1;}
获取 UDP 协议统计信息的两个非常有用的文件是:
/proc/net/snmp
/proc/net/udp
/proc/net/snmp
读取 /proc/net/snmp
监控详细的 UDP 协议统计信息。
$ cat /proc/net/snmp | grep Udp\:Udp: InDatagrams NoPorts InErrors OutDatagrams RcvbufErrors SndbufErrorsUdp: 16314 0 0 17161 0 0
与此文件中 I P协议的详细统计信息非常相似,您需要阅读协议层源文件,以准确确定这些值在何时何地递增。
InDatagrams
:当用户程序使用 recvmsg
读取数据报时递增。当 UDP 数据包被封装并发送回来进行处理时也会递增。NoPorts
:当 UDP 数据包到达目标端口,但没有程序在监听时递增。InErrors
:在几种情况下递增:接收队列中没有内存,检测到校验和错误,以及如果 sk_add_backlog
未能添加数据报。OutDatagrams
:当 UDP 数据包无错误地传递给 IP 协议层发送时递增。RcvbufErrors
:当 sock_queue_rcv_skb
报告没有可用内存时递增;如果 sk->sk_rmem_alloc
大于或等于 sk->sk_rcvbuf
时会发生这种情况。SndbufErrors
:如果 IP 协议层在尝试发送数据包时报告错误且未设置错误队列,则递增。如果没有可用的发送队列空间或内核内存也会递增。InCsumErrors
:当检测到 UDP 校验和失败时递增。请注意,在我能找到的所有情况中,InCsumErrors
都与 InErrors
同时递增。因此,InErrors - InCsumErros
应该得出接收端内存相关错误的计数。/proc/net/udp
读取 /proc/net/udp
监控 UDP 套接字统计信息
$ cat /proc/net/udp sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ref pointer drops 515: 00000000:B346 00000000:0000 07 00000000:00000000 00:00000000 00000000 104 0 7518 2 0000000000000000 0 558: 00000000:0371 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7408 2 0000000000000000 0 588: 0100007F:038F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7511 2 0000000000000000 0 769: 00000000:0044 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7673 2 0000000000000000 0 812: 00000000:006F 00000000:0000 07 00000000:00000000 00:00000000 00000000 0 0 7407 2 0000000000000000 0
第一行描述后续行中的每个字段:
sl
:套接字的内核哈希槽local_address
:套接字的十六进制本地地址和端口号,用 :
分隔。rem_address
:套接字的十六进制远程地址和端口号,用 :
分隔。st
:套接字的状态。奇怪的是,UDP 协议层似乎使用了一些 TCP 套接字状态。在上面的示例中,7
是 TCP_CLOSE
。tx_queue
:内核为传出 UDP 数据报分配的内存量。rx_queue
:内核为传入 UDP 数据报分配的内存量。tr
、tm->when
、retrnsmt
:这些字段未被 UDP 协议层使用。uid
:创建此套接字的用户的有效用户 ID。timeout
:未被 UDP 协议层使用。inode
:与此套接字对应的 inode 编号。您可以使用它来帮助您确定哪个用户进程打开了此套接字。检查 /proc/[pid]/fd
,其中包含指向 socket[:inode]
的符号链接。ref
:套接字的当前引用计数。pointer
:内核中 struct sock
的内存地址。drops
:与此套接字关联的数据报丢弃数。 请注意,这不包括任何与发送数据报有关的丢弃(在 corked 的 UDP 套接字上,或其他);在本博客考察的内核版本中,只在接收路径中增加。可以在 net/ipv4/udp.c
中找到输出此内容的代码。
网络数据调用 sock_queue_rcv
排队到套接字。在添加数据报到队列之前,此函数会执行一些操作:
sk_filter
处理已应用于套接字的 Berkeley Packet Filter 过滤器。sk_rmem_schedule
,以确保有足够的接收缓冲区空间来接受此数据报。skb_set_owner_r
将数据报的大小计入套接字。这会增加 sk->sk_rmem_alloc
。__skb_queue_tail
添加数据到队列中。sk_data_ready
通知处理程序函数通知任何等待套接字中数据到达的进程。这就是数据如何到达系统并遍历网络堆栈,直到它到达套接字并准备好被用户程序读取。
有一些额外的事情值得一提,值得一提的是,似乎不太正确的其他任何地方。
正如上面的博客文章中提到的,网络栈可以收集传出数据的时间戳。 请参阅上面的网络栈演练,了解软件中的传输时间戳发生的位置。 一些 NIC 甚至还支持硬件中的时间戳。
如果您想尝试确定内核网络栈在发送数据包时增加了多少延迟,这是一个有用的特性。
关于时间戳的内核文档非常好,甚至还有一个包含的示例程序和 Makefile,你可以查看!
使用 ethtool -T
确定您的驱动程序和设备支持的时间戳模式。
$ sudo ethtool -T eth0Time stamping parameters for eth0:Capabilities: software-transmit (SOF_TIMESTAMPING_TX_SOFTWARE) software-receive (SOF_TIMESTAMPING_RX_SOFTWARE) software-system-clock (SOF_TIMESTAMPING_SOFTWARE)PTP Hardware Clock: noneHardware Transmit Timestamp Modes: noneHardware Receive Filter Modes: none
不幸的是,这个网卡不支持硬件接收时间戳,但是软件时间戳仍然可以在这个系统上使用,以帮助我确定内核给我的数据包接收路径增加了多少延迟。
可以使用名为 SO_BUSY_POLL
的套接字选项,当执行阻塞接收且没有数据时,它会导致内核忙碌轮询新数据。
重要提示:要使此选项正常工作,您的设备驱动程序必须支持它。Linux 内核 3.13.0 的 igb
驱动程序不支持此选项。然而,ixgbe
驱动程序支持。如果您的驱动程序在其 struct net_device_ops
结构(在上面的博客文章中提到)的 ndo_busy_poll
字段中设置了一个函数,则它支持 SO_BUSY_POLL
。
Intel 提供了一篇很棒的论文,解释了这是如何工作的以及如何使用它。
当为单个套接字使用此套接字选项时,您应该传递一个以微秒为单位的时间值,作为在设备驱动程序的接收队列中忙碌轮询新数据的时间。在设置此值后,当您对此套接字发出阻塞读取时,内核将忙碌轮询新数据。
您还可以设置 sysctl 值 net.core.busy_poll
为以微秒为单位的时间值,表示使用 poll
或 select
的调用应忙碌轮询等待新数据到达的时间。
此选项可以减少延迟,但会增加 CPU 使用率和功耗。
Linux 内核提供了一种方法,可以在内核崩溃时使用设备驱动程序在 NIC 上发送和接收数据。这个 API 被称为 Netpoll,它被一些东西使用,但最值得注意的是:kgdb、netconsole。
大多数驱动程序都支持 Netpoll;您的驱动程序需要实现 ndo_poll_controller
函数,并将其关联到探测期间注册的 struct net_device_ops
(如上所示)。
当网络设备子系统对传入或传出数据执行操作时,首先检查 netpoll 系统以确定数据包是否目标为 netpoll。
例如,我们可以在 __netif_receive_skb_core
中看到以下代码,来自 net/dev/core.c
:
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc){ /* ... */ /* if we've gotten here through NAPI, check netpoll */ if (netpoll_receive_skb(skb)) goto out; /* ... */}
Netpoll 检查发生在大多数处理传输或接收网络数据的 Linux 网络设备子系统代码之前。
Netpoll API 的使用者可以调用 netpoll_setup
来注册 struct netpoll
结构。struct netpoll
结构具有关联接收钩子的函数指针,API 导出了一个发送数据的函数。
如果您对使用 Netpoll API 感兴趣,您应该查看 netconsole
驱动程序、Netpoll API 头文件 include/linux/netpoll.h
和 这个优秀的演讲。
SO_INCOMING_CPU
SO_INCOMING_CPU
标志直到 Linux 3.19 才被添加,但它非常有用,应该包含在此博客文章中。
您可以使用 getsockopt
和 SO_INCOMING_CPU
选项来确定哪个 CPU 处理特定套接字的网络数据包。然后,您的应用程序可以使用此信息将套接字交给在所需 CPU 上运行的线程,以帮助增加数据局部性和 CPU 缓存命中。
引入 SO_INCOMING_CPU
的邮件列表消息提供了一个简短的示例架构,其中此选项很有用。
DMA 引擎是一种硬件,它允许 CPU 卸载大型复制操作。这使得 CPU 使用硬件完成内存复制时可以执行其他任务。启用 DMA 引擎并运行利用它的代码,应该会降低 CPU 使用率。
Linux 内核具有通用的 DMA 引擎接口,DMA 引擎驱动程序作者可以插入。您可以在 内核源代码文档 中了解更多关于 Linux DMA 引擎接口的信息。
尽管内核支持一些 DMA 引擎,但我们将讨论一种非常常见的特定 DMA 引擎:Intel IOAT DMA 引擎。
许多服务器都包含 Intel I/O AT 组件包,它由一系列性能更改组成。
其中一个更改是包含硬件 DMA 引擎。您可以检查 dmesg
输出中的 ioatdma
,以确定模块是否正在加载并且是否找到了支持的硬件。
DMA 卸载引擎在几个地方使用,最值得注意的是在 TCP 栈中。
对 Intel IOAT DMA 引擎的支持包含在 Linux 2.6.18 中,但由于一些不幸的 数据损坏错误,它在 3.13.11.10 中被禁用。
在 3.13.11.10 之前的内核上的用户可能默认在其服务器上使用 ioatdma
模块。也许这将在未来的内核版本中得到修复。
与 Intel I/O AT 组件包 一起包含的另一个有趣功能是直接缓存访问 (DCA)。
此功能允许网络设备(通过其驱动程序)直接放置网络数据到 CPU 缓存中。具体如何实现这一点是特定于驱动程序的。对于 igb
驱动程序,您可以检查 函数 igb_update_dca
的代码,以及 igb_update_rx_dca
的代码。igb
驱动程序向 NIC 写入寄存器值来使用 DCA。
要使用 DCA,您需要确保在 BIOS 中启用了 DCA,加载了 dca
模块,并且您的网络卡和驱动程序都支持 DCA。
如果您正在使用 ioatdma
模块,尽管有上面提到的数据损坏的风险,您可以检查 sysfs
中的一些条目监控它。
监控 DMA 通道的卸载 memcpy
操作总数。
$ cat /sys/class/dma/dma0chan0/memcpy_count123205655
类似地,要获取此 DMA 通道卸载的字节数,可以运行以下命令:
监控 DMA 通道传输的总字节数。
$ cat /sys/class/dma/dma0chan0/bytes_transferred131791916307
IOAT DMA 引擎仅在数据包大小高于某个阈值时使用。 这个阈值被称为 copybreak
。 之所以进行此检查,是因为对于小型副本,设置和使用 DMA 引擎的开销不值得加速传输。
使用 sysctl
调整 DMA 引擎 copybreak
。
$ sudo sysctl -w net.ipv4.tcp_dma_copybreak=2048
默认值为 4096。
Linux 网络堆栈非常复杂。
如果不深入了解究竟发生了什么,就不可能监控或调优它(或任何其他复杂的软件)。通常,在互联网的荒野中,您可能会偶然发现一个包含一组 sysctl 值的示例 sysctl.conf
,复制并粘贴到您的计算机上。这可能不是优化您的网络堆栈的最佳方法。
监控网络堆栈需要在每一层仔细核算网络数据。从驱动程序开始,然后向上进行。这样您就可以确定丢弃和错误发生在哪里,然后调整设置以确定如何减少您看到的错误。
不幸的是,没有简单的出路。
原文: Monitoring and Tuning the Linux Networking Stack: Receiving Data
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/04-24-2023/monitoring-and-tuning-the-linux-networking-stack-recv-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
作者序
本文所谈的绝大部分内容在众多文章中都有讲到,再复述一遍并非本意。本文的目的是了解各种工具、定量分析网络状态;当遇到网络性能问题的时候,根据原理和出现的可能性,有的放矢。
其他说明:
应用程序发送到协议栈的数据长度是由应用程序本身决定的。不同的应用程序有不同的实现方式,有些应用程序一次性发送所有数据,而有些应用程序则会逐字节或逐行发送数据。最终,发送到协议栈的数据量由应用程序决定,协议栈无法控制这种行为。
如果协议栈一接收到数据就立即发送,可能会发送大量的小数据包,导致网络效率降低。因此,需要在累积一定数量的数据后再发送。但是,累积多少数据才能发送取决于操作系统的种类和版本。
现在,假设有一个需要写入的操作比较大,例如 4000 字节,那么 TCP 层会如何处理呢?是否只需添加 TCP 标头并将其发送到网络层呢?
答案是否定的。因为网络对数据包大小有限制,最大传输单元(MTU,Maximum transmission unit)指的是网络可以传输的最大数据包大小。大多数网络的 MTU 为 1500 字节,这意味着 4000 字节的数据包要么会被丢弃,要么会被分片。如果数据包被丢弃,传输将彻底失败。如果数据包被分片,将会导致传输效率降低。
那被切分的包又该怎么重组呢?
仍然以一个数据包大小为 4000 字节,MTU 为 1500 字节为例,当发送端的 IP 层将该数据包发送到网络层时,会检查数据包大小是否超过 MTU 限制。
如果超过了,IP 层会将该数据包分成三个分片,分别是:
分片的重组需要依据 IP Header 中的标识(Identification)和标志(Flags)字段来完成。标识字段用于标识分片属于哪个数据报,而标志字段用于标识分片是否允许再分片和是否为最后一片。具体而言,同一个数据报的所有分片都应该具有相同的标识字段值,而 DF(Don’t Fragment,不分片)和 MF(More Fragments,还有更多分片)标志则用于标识分片是否允许再分片和是否为最后一片。
在接收端,当接收到这些分片时,它们会根据标识字段进行分类。如果一个数据报的所有分片都到达了接收端,那么接收端就可以使用偏移量和分片大小将这些分片按正确的顺序重新组装成原始数据包。如果某个分片没有到达接收端,那么接收端会等待一段时间,如果超时后仍然没有收到该分片,那么接收端就会向发送端发送一个请求重传的消息。
假设客户端和服务器的 MTU 大小分别为 1500 和 1200 字节。在这种情况下,客户端最大能发出多少字节的包呢?
根据上面的结论,发包的大小是由 MTU 较小的一方决定的,因此客户端最大只能发送 1200 字节的包。如果客户端尝试发送 1500 字节的包,那么这个包将被分片成两个部分,每个部分的大小分别为 1200 字节和 300 字节。如果 DF 标志位设置为 1,表示不允许分片,因此这个数据包会则会被丢弃,传输失败。
每个 IP 包都有一个 TTL 字段,表示该包的生存时间。每当一个 IP 包经过一个路由器,TTL 字段就会减 1,当 TTL 为 0 时,该包就会被丢弃。根据 TTL 的特性,只需翻出网络拓扑图,就能大概知道该包是哪台设备发出。
除此之外,TTL 还可以用于检测网络劫持和请求延迟问题。如果我们怀疑网络连接被劫持,可以通过检查 TTL 值来确定是否存在额外的跳数。而如果请求延迟较高,也可以通过检查 TTL 值来确定是否存在较远的跳数,从而进一步分析网络瓶颈所在。
由于 IP 层 MTU 的存在,TCP 协议需要控制 MTU,从而避免数据过大而需要分包传输的问题,提高网络传输效率。
在 TCP 连接建立过程中,客户端和服务器会互相通告各自的 MSS(Maximum Segment Size,最大分段大小),MSS 是指 TCP 数据段中数据部分的最大长度。MSS 加上 TCP 头和 IP 头的长度,就是双方可以承载的最大 MTU。
RTT(往返时延)是指从发送方发送数据到发送方接收到来自接收方的确认消息所经过的时间。在网络通信中,RTT 时延不仅与链路的传播时间有关,还包括路由器等网络中间节点的缓存和排队时间,以及末端系统的处理时间。
尽管在同一条链路上,报文的传输时间和应用处理时间相对固定,但网络设备和末端系统的网络拥堵情况下,排队时间的增加会导致 RTT 时延波动。
此外,流量负载均衡的存在会导致选择的传输路径和经过的网络设备不同,即使是同一个上下游服务的请求,也会出现 RTT 时延的差异。
MTR(My Traceroute)是一种网络诊断工具,可以通过在连续的时间间隔内将网络节点的 traceroute(跟踪路由)操作的结果显示在同一屏幕上,从而提供更详细的网络信息。
使用 MTR 可以帮助我们了解数据包在网络中的路径和每个跃点的 RTT,从而更方便地定位网络问题。例如,如果我们发现某些数据包延迟较高,我们可以使用 MTR 查看这些数据包的路径和每个跃点的 RTT,以确定延迟出现的具体位置。此外,MTR 还可以通过连续的监测,提供有关网络稳定性和性能的有用信息,从而帮助我们优化网络性能
在我们日常生活中,排队和拥挤现象时常发生,如医院看病、邮局等候服务等。除了实际排队之外,还有一种无形的排队,即网络拥堵导致的网速变慢。
为了减少排队现象,增加服务窗口是一个可行的解决方案,但这也会增加服务成本。反之,缩小服务窗口可以提高窗口的利用率,降低成本,但会增加用户排队等待的时间。这两者是相互矛盾的。
为了在保证用户满意度(响应时间)的前提下,最大限度地挖掘系统潜力、提高利用率(控制成本),TCP 通过窗口(发送窗口/拥塞窗口)大小来实现这一目标。
由于无法确认接收方是否能及时接收数据包,TCP 传输中不适合每发一个数据包就停下来等待确认,因为这样传输效率太低。最好的方式是一次性将所有数据包发送出去,然后一起等待确认。但是,实际情况存在一些限制:
因此,在 TCP 传输中,发送窗口通过限制发送数据包的数量来平衡传输效率和数据可靠性。发送窗口的大小计算公式为 wnd = min(rwnd, cwnd * mss),其中 rwnd 表示接收方告知的接收窗口大小,cwnd 表示发送方的拥塞窗口大小。在此限制范围内,尽可能多地发送数据包,一次可以发送的数据量即为 TCP 发送窗口。
发送窗口大小对传输性能的影响非常大。下图显示了发送窗口大小为 1 个 MSS(即每个 TCP 包所能携带的最大数据量)和 2 个 MSS 时的差别。在相同的往返时间内,发送窗口大小为 2 个 MSS 时,传输的数据量是发送窗口大小为 1 个 MSS 的两倍。
在实际应用中,发送窗口通常可以达到数十个 MSS 的大小,因此发送窗口的大小会对 TCP 传输的效率和可靠性产生巨大影响。
发送窗口 VS MSS
发送窗口决定了一口气能发多少字节,而 MSS 决定了这些字节要分多少个包发完。例如:
发送窗口为 16000 字节,MSS 为 1000 字节时,需要发送 16000/1000=16 个包;而如果 MSS 等于 8000,那要发送的包数就是 16000/8000=2。
在 TCP 协议中,接收窗口是一项非常重要的参数,它决定了发送方在一个确定时间内可以发送多少数据。
在 TCP 协议初期,网络带宽非常有限,因此 TCP 的最大接收窗口被定义为 65535 字节。但随着网络带宽的提高,这个值已经无法满足现代网络传输的需求了。
- 如果抓包时没有抓到三次握手,Wireshark 就不知道该如何计算,所以有时候会很莫名地看到一些极小的接收窗口值。
- 如果防火墙识别不了 Window Scale,因此对方无法获得 Shift count,最终导致严重的性能问题。
1992 年,RFC 1323 提出了一种解决方案,即在三次握手时向对方发送自己的 Window Scaling 信息,Window Scaling 是一个 2 的指数,通过它可以计算出实际的 TCP 接收窗口大小。这个方案的好处是可以不需要修改 TCP 头的设计。
# 查看 Linux 内核 TCP Window Scalingsysctl net.ipv4.tcp_window_scaling> net.ipv4.tcp_window_scaling = 1# 设置 Linux 内核 TCP Window Scalingsudo sysctl -w net.ipv4.tcp_window_scaling=0> net.ipv4.tcp_window_scaling = 0
拥塞控制的基本思想是发送方通过维护一个虚拟的拥塞窗口,控制数据包的发送速度,以防止网络拥塞。
具体怎么知道窗口多大会触发拥塞呢?
假设我们要计算的是某个 TCP 连接的拥塞点,而在该连接中存在一连串重传包。首先,我们需要找到重传包序列中的第一个包,然后根据其 Seq 值找到其对应的原始包,进而计算出原始包发送时刻的在途字节数。因为网络拥塞发生在该原始包发送的时刻,因此该时刻的在途字节数大致代表了拥塞点的大小。
在途字节数的计算公式应该是:
在途字节数 = Seq + Len - Ack
其中,Seq 是指包的序列号,Len 是包的长度,Ack 是指确认号。
具体步骤:
在传输数据时,由于网络拥塞、硬件故障等原因导致数据包未能及时到达接收方,发送方会重新发送该数据包。
在网络传输过程中,丢包是很常见的问题,不过有时候出现的丢包症状并不像严重拥塞时那么明显。一些因素如校验码不对可能导致单个包的丢失,或者只有少量的包丢失。当这些包的后续包能够正常到达接收方时,接收方会发现其 Seq 号比期望的大,为了提醒发送方重传这些包,接收方会每收到一个包就 Ack 一次期望的 Seq 号。当发送方接收到三个或以上的重复确认(Dup Ack)时,发送方便会意识到相应的包已经丢失,于是立即重传它。这个过程称为快速重传,与超时重传不同,它无需等待一段时间。
为什么要规定收到 3 个或以上的重复确认才会重传呢?这是因为网络包有时会乱序,乱序的包同样会触发重复的 Ack,但是为了乱序而重传却是不必要的。因为一般乱序的距离不会相差太大,比如 2 号包也许会跑到 4 号包后面,但不太可能跑到 6 号包后面。所以规定收到三个或以上的重复确认,可以在很大程度上避免因乱序而触发快速重传。
如下图所示,2 号包的丢失凑满了 3 个 Dup Ack,所以触发快速重传。而在右图中,2 号包跑到 4 号包后面,但因为凑不满 3 个 Ack,所以没有触发快速重传。
如果在拥塞避免阶段发生了快速重传,是否需要像发生超时重传一样处理拥塞窗口呢?
其实并没有必要。因为如果后续的包都能正常到达,那么说明网络并没有严重拥塞,只需要在接下来传输数据时减缓一些速度即可。
RFC 5681 规定,在发生拥塞时还没被确认的数据量的 1/2(但不能小于 2 个 MSS)设为临界窗口值。然后将拥塞窗口设置为临界窗口值加 3 个 MSS,继续保留在拥塞避免阶段。这个过程被称为快速恢复,其拥塞窗口的变化可以用下图表示:
在网络中,发生拥塞后会影响到发送方,因为发送方发送的数据包可能无法像往常一样得到及时的确认。当无法收到确认时,发送方会等待一段时间来判断是否存在网络延迟。如果超过了一定时间仍然没有收到确认,发送方就会认为这些数据包已经丢失,只能通过重传来保证数据的正确性。这个过程被称为超时重传,而从发送原始数据包到重传该数据包的这段时间被称为 RTO。
在 Linux 内核编译时,RTO 的最小值就已被确定,默认值为:200 ms
#define TCP_RTO_MAX ((unsigned)(120*HZ))#define TCP_RTO_MIN ((unsigned)(HZ/5))
然而,超时重传对传输性能有严重的影响。
即使是一次万分之一的超时重传,也可能对传输性能产生不可忽视的影响。
如何检查重传情况呢?
Wireshark 单击 Analyze–>Expert Info Composite 菜单,就能在 Notes 标签看到它们了,如图所示。点开 + 号还能看到具体是哪些包发生了重传。
从 Notes 标签中看到 Seq 号为 1458613 的包发生了超时重传。于是用该 Seq 号过滤出原始包和重传包(只有在发送方抓的包才看得到原始包),发现 RTO 竟长达 1 秒钟以上。这对性能的影响实在太大了。找出瓶颈彻底消除重传之后:
SACK(Selective Acknowledgment 选择性确认)是一种重传机制,其可以向发送方发送接收状态信息。通过 SACK,发送方可以准确地知道哪些数据包已经被接收,哪些数据包还未接收到,从而只需要重传丢失的数据包。
在真实环境中,我们可以抓取到 SACK 的实例。结合“Ack = 991851”和“SACK = 992461-996175”这两个条件,发送方可以知道 992461-996175 的数据已经被接收,而 991851-992460 的数据则还未被接收。这为重传丢失的数据包提供了有力的指引。
除了众所周知的算法外,Linux 内核还提供了多个 TCP 拥塞控制算法,这些算法具有不同的传输特性,可以在 TCP 传输的重要指标,如往返传输时延(RTT)和吞吐量方面表现出不同的效果,包括:Reno、Cubic、BIC、Westwood+、Highspeed、Hybla 等。
# 查询支持的TCP拥塞控制算法sysctl net.ipv4.tcp_available_congestion_control> net.ipv4.tcp_available_congestion_control = reno cubic bbr# 查询应用的TCP拥塞控制算法sysctl net.ipv4.tcp_congestion_control> sysctl net.ipv4.tcp_congestion_control
在实际应用中,我们可以根据具体需求和网络环境选择合适的 TCP 拥塞控制算法,以达到最佳的网络传输效果。
为了保证数据的可靠性,它使用了流量控制、拥塞控制、确认机制等多种技术,这些技术都需要消耗网络带宽和处理资源。
当发送端发送的数据包大小过小时,就会导致网络中出现大量的TCP头部、IP头部等固定长度的协议头。因为一个 TCP 包的头部和 IP 头部至少会占用 40 个字节的空间,而携带的数据很小时就像快递员开着大货车去送小包裹一样浪费。
协议头会占用大量的网络带宽和处理资源,从而导致网络传输效率下降。为了避免TCP小包问题,发送端可以使用一些方法来增加数据包的大小,比如使用 Nagle算法、延迟确认。
Nagle 算法的原理是在发出去的数据还没有被确认之前,如果有小数据生成,就先把这些小数据收集起来,凑满一个最大报文段长度(MSS)再进行发送。这样可以减少网络中的小数据包,提高网络的利用率。
延迟确认的原理是这样的:如果接收方收到一个数据包后没有需要立即回复的数据要发送给发送方,那么它就会延迟一段时间再发送确认信息。如果在这段时间内有需要发送的数据,那么确认信息和数据就可以在同一个数据包中一起发送出去。
当与 Nagle 算法同时启用时,延迟确认可能会导致性能下降
理解 TCP 协议的机制和字段含义,是为了当传输性能问题发生时,更好地应用它。
当出现延迟问题时:
延迟指标 = 新建连接耗时 + RTT + (Retransmission + RTO) + (Fast Retransmission + Dup ACK) + Retransmission(Out-Of-Order) - SACK + Delay ACK + Nagle Algorithm
类似的,当出现吞吐问题时:
吞吐指标 = (总耗时 - (新建连接耗时 + 重传耗时 + RTO 耗时))/ RTT * MSS * (Cwnd / MSS) - Retransmission
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/03-30-2023/network-transmission.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
作者序
本文目的在于按照自己的理解,解释清楚网络中的一些基本概念,以及支撑概念落地的网络设备的工作原理。从而解决网络联通性问题,以及为定量分析网络性能问题打基础。如有错漏,欢迎指正:
网络世界与现实世界在许多方面运作方式相似。就像现实世界中的地址一样,划分国家、省市、街道、小区。邮递员以此高效的将快递正确送达每家每户。在网络世界中,IP 地址是用于唯一标识网络中的设备的,但是当网络规模变得很大时,就需要将 IP 地址进行划分,划分为若干个子网。子网使网络更高效。通过子网划分,网络流量传播距离更短,无需通过不必要的路由器即可到达目的地。
子网划分的过程需要在网络层上进行,可以通过在 IP 地址中使用子网掩码(Subnet Mask)来划分子网。子网掩码是一个 32 位的二进制数,与 IP 地址进行逻辑运算,可以将网络号和主机号进行区分。
例如,如果将 IP 地址(192.168.1.0)分成 4 个子网,可以使用 255.255.255.192 的子网掩码进行划分,得到四个子网:
- 192.168.1.0/26
- 192.168.1.64/26
- 192.168.1.128/26
- 192.168.1.192/26
不难看出,网络世界的运作很类似“分治策略”,可以将以上网络模型简化为 “子问题”(广域网 WAN) + 初始值(局域网 LAN)
局域网(LAN, Local Area Network)是指在较小的地理范围内,由计算机、打印机、服务器等设备组成的局域网,它们可以通过物理链路或者无线信号相互连接,形成一个逻辑上的网络。在 LAN 中,所有设备可以直接通信,不需要经过路由器进行 IP 路由,因此都处于同一个广播域内。
广域网(WAN, Wide Area Network)是一种大型计算机网络,用于远距离连接不同的计算机组。大型企业通常使用 WAN 来连接其办公网络;每一办事处通常有自己的局域网(或 LAN),这些 LAN 通过 WAN 相连。
在此模型下,首先回顾下协议栈的分层;然后,再来认识网络设备是如何落地协议栈,并完成工作的。
TCP/IP 包含如下两个头部。
协议栈分层中 IP 和 Ethernet 分开的目的在于支撑除了以太网在内的各种通信技术,例如无线局域网、ADSL、FTTH 等。它们都可以替代以太网的角色帮助 IP 协议来传输网络包。
两个头部分别具有不同的作用。首先,发送方将包的目的地,也就是要访问的服务器的 IP 地址写入 IP 头部中。如此就知道这个包应该发往哪里,IP 协议就可以根据这一地址查找包的传输方向,从而找到下一个路由器的位置。接下来,IP 协议会委托以太网协议将包传输过去。IP 协议会查找下一个路由器的以太网地址(MAC 地址),将包将地址写入 MAC 头部中。如此,以太网协议就知道要将这个包发到哪一个路由器。
同时,也意味着,经过每一跳的网络设备都会经过“解包”和“封包”,最核心的变化是 MAC 地址会被更新为下一跳的网络设备的地址(IP 地址保持不变)
现如今,网络设备的集成度越来越高,像上图这样使用独立设备的情况很少见。例如家用路由器,集成了集线器和交换机的功能。
不过,把每个功能独立出来更容易理解,而且理解了这种模式之后,也就能理解集成多种功能的设备,因此下面将所有功能独立出来,逐个来进行探索。
路由器作为三层网络设备的代表,在其中扮演着非常重要的角色。路由器先构建路由表,以确定如何将数据包从一个网络转发到另外一个网络。
路由的核心功能可以分为两个部分,“路由选择”(确定通过网络的最佳路径的任务) 和 “分组转发”(将数据包从一个接口移动到另一个接口的任务)。就像计算机一样,通过更换网卡(NIC),路由器不仅可以支持以太网,也可以支持无线局域网。
路由表是路由器中的一个表格,包含着可用的路由信息,包括目标网络地址和下一跳路由器的地址。当路由器接收到一个数据包时,会将数据包的目标 IP 地址与每一条路由表项的目的 IP 地址进行匹配。如果有多条匹配的路由表项,则选择最长的前缀匹配,并将数据包转发到该前缀所对应的网络。
最长的前缀匹配指的是,路由表项中目的 IP 地址的子网掩码位数最长的项。例如,路由表中有以下三条路由表项:
- 10.0.0.0/8
- 10.1.0.0/16
- 0.0.0.0/0
当路由器收到一个目标 IP 地址为 10.1.2.3 的数据包时,会先与第二个路由表项(10.1.0.0/16)进行匹配,因为它的前缀长度更长(16 位)比第一个路由表项(8 位)更精确。因此,路由器会将数据包转发到与第二个路由表项对应的下一跳路由器。
路由器拥有内网的 IP 路由表,同时还拥有一条神奇的路由 0.0.0.0/0。0.0.0.0/0 路由是一种默认路由,也称为默认网关或缺省路由。它指示路由器在找不到更具体的路由表项来匹配目标 IP 地址时,将数据包发送到默认网关,最终到达核心网。
路由器有一个非常独立的控制体系。先有控制层面,再有数据层面。先有控制层面,才会知道一个一个网络怎么走,知道网络怎么走之后,再基于数据层面,接收数据,查读路由表,来进行数据转发。路由表的构建方式有以下几种方式:
接收到的数据包由链路层协议控制器处理,该控制器处理物理链路(电缆)上使用的链路层协议,会检查接收到的帧的完整性(大小、校验和、地址等)。有效帧通过去除链路层报头(解封)转换为数据包,并在接收队列中排队。这通常是一个先进先出 (FIFO) 队列,通常采用内存缓冲区环的形式。
每个传出数据包都需要添加一个新的链路层协议报头(封装),并将目标地址设置为下一个接收数据包的系统。链路协议控制器还维护与接口相关的 硬件地址表。 通常涉及使用地址解析协议 ( ARP) 找出直接连接到同一电缆(或 LAN )的其他计算机或路由器的硬件(MAC 地址). 数据包最终使用媒体接口发送,硬件地址设置为下一跳系统。
为了确保 IP 数据包在网络上具有有限的生存期,所有 IP 数据包都有一个 8 位的 TTL(IPv4)或 Hop Limit(IPv6)报头字段和值,当一个路由器接收到一个数据包时,它会将 TTL 或 Hop Limit 减 1,然后再将数据包转发到下一个路由器。如果 TTL 或 Hop Limit 的值减少到 0,路由器将丢弃数据包并向源主机发送 ICMP 错误消息,通知它数据包已经超时。
MAC 地址是硬件地址,与设备的网卡绑定,二层交换机通过学习连接的每个终端的 MAC 地址,将数据发送给对应的目 的终端上,避免将数据发送到无关端口,提供了网络利用率。下次再遇到相同的 MAC 地址时,可以直接从缓存中获取对应的端口信息。
另外一种情况,由于广播域(二层互通)的存在,每个设备都能够直接访问到同一广播域内的所有其他设备。如果是没有学习到的 MAC 地址,或者想跟网段内所有终端进行通信,交换机会使用广播方式,将数据帧进行泛洪,无需对目标设备进行地址解析和寻址,可以更快速地定位和转发数据包。然后只有相应的接收者才接收包,而其他设备则会忽略这个包。
举例,有三台电脑连接同一台交换机,计算机的 MAC 地址简化为 AAA、BBB 和 CCC。现在,假设计算机 A 要向计算机 B 发送一些信息:
交换机将建立一个 MAC 地址表,并且只从源 MAC 地址中学习。此时,它刚刚得知计算机 A 的 MAC 地址在接口 1 上。它现在将在其 MAC 地址表中添加此信息。但交换机目前没有计算机 B 所在位置的信息。因此只能将此帧从其所有除来源之外的接口中洪泛出来。计算机 B 和计算机 C 将接收该以太网帧。
由于计算机 B 将其 MAC 地址视为该以太网帧的目的地,它知道它是为他准备的,计算机 C 将丢弃它。计算机 B 将响应计算机 A,构建一个以太网帧并将其发送给交换机。此时,交换机将学习计算机 B 的 MAC 地址。
当同一个交换机下主机越来越多,网络规模越大,广播域就越大,泛洪流量也越来越大,降低通信效率。在一个广播域内的任意两台主机之间可以任意通信,通信数据有被窃取的风险。
有两种方案可以解决这个问题:
对于分布在不同交换机之下同一个 VLAN 的主机如何互达呢?对于支持 VLAN 的交换机,有一种口叫作 Trunk 口。它可以转发属于任何 VLAN 的口。交换机之间可以通过这种口相互连接,即可保证同一个 VLAN 互达。
二层交换机通过使用 VLAN 分隔广播域,位于同一个 VLAN 下的终端才能进 行数据帧交互。对于不同 VLAN 的终端有通信需求时,就必须使用路由功能, 也就是需要额外添加路由器。二层交换机和路由器组合使用,才能完成跨 VLAN 的通信。基于类似的需求,三层交换机应运而生。使用三层交换机就不需要其它网络设备,能够直接完成不同 VLAN 之间的通信。
集线器工作在物理层,以太网 LAN 的一种中继器形式,具有多个端口(它们有时也称为“多端口中继器”或“活动星形网络”)。
每个端口(或接口)允许一台设备连接到集线器。通过端口 F 连接的系统正在向端口 C 连接的系统发送一帧数据。集线器由于工作于物理层,无法识别帧头中的地址,因此无法识别要发送到哪个端口到。因此,采用“广播模式”,每一帧都被发送到每个输出端口,然后让主机来判断是否需要。
举例来源:《Wireshark 网络分析就这么简单》
两台服务器 A 和 B 的网络配置如下图,B 的子网掩码本应该是 255.255.255.0,被不小心配成了 255.255.255.224。它们还能正常通信吗?
以上哪个答案是正确的?还是都不正确?如果这是你第一次听到这道题,不妨停下来思考一下。
答案揭晓:B 先把请求交给默认网关,默认网关再转发给 A。而 A 收到请求后直接回复给 B,形成如下所示的三角形环路。不知道你答对了吗?
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/03-19-2023/network-device-and-concept.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
作者序
本文是一篇 AI 辅助创作的内容。作者的工作内容发生一些的变化,开始转变为不断提出问题、丰富和拓展内容、编辑校研内容。
毫无疑问,“AI 辅助” 将变革当前的工作方式,未来已来。
在 如何设计 RPC 接口 中讲到一个观点:
资源在用户侧以 hyper media 存在;资源流到服务中以对象来组织;资源落到存储里就变成了
id
+content
。索引content
的 id,一般又以单个
和集合
的形态存在,具体到数据库中,id 以 聚簇索引存在,content 以聚簇索引叶节点存在越来越多的产品按照先获取
id
再读取content
来访问资源
Redis 是一个高效的键值存储数据库,可以用来存储对象(Content)。 在 Redis 中,可以使用 String 和 Hash 来存储对象。在生产环境经常看到不少的误用,导致低效的空间利用率、存取性能、以及可靠性。怎么存就决定了怎么取,Redis 数据结构选择也能见方案设计者的设计功力。
在实际的应用场景中,常见的使用方式有以下三种:
JSON 是一种轻量级的数据交换格式,常用于前后端之间的数据传输。Redis 中可以存储 JSON 对象,通常使用字符串类型(string)来存储 JSON 数据。将 JSON 对象序列化成字符串并将其存储在 Redis 中,然后在需要时将其反序列化回 JSON 对象。
优点:
缺点:
备注: JSON 也可以替换成 Protobuf,性能更好,成本更低,思路一致。
多个字符串(multiple string)是指将一个对象的多个属性分别存储在 Redis 中不同的字符串键值对中。例如,将一个用户对象的用户名、邮箱、密码等属性存储在不同的 Redis 字符串中。
优点:
缺点:
哈希(hash)是 Redis 中的一种特殊数据类型,可以将一个对象存储为一个 Redis 哈希,其中对象的属性存储为哈希的字段,属性的值存储为哈希的值。例如,将一个用户对象存储为 Redis 哈希,其中用户名、邮箱和密码是哈希的字段,相应的值是哈希的值。
优点:
缺点:
除了需求,考虑存储空间和存取性能
对于存储空间而言,可以根据具体的数据结构来选择最合适的存储方式。如果数据结构比较简单,使用 JSON+String 可能是比较好的选择,因为 JSON 格式可以非常紧凑,而字符串类型也是 Redis 支持的最基本的数据类型之一,占用的空间比较小。如果数据结构比较复杂,可以考虑使用哈希来存储对象,因为哈希可以将多个属性存储在同一个键值对中,相比于多个字符串,可以减少存储空间的占用。
对于存取性能而言,可以根据具体的应用场景来选择最合适的存储方式。如果需要快速地读取或更新对象的某些属性,可以考虑使用多个字符串或哈希,因为这些方式可以通过对单个属性进行操作来实现,相比于读取或更新整个对象,可以减少网络延迟和代码复杂度。如果需要快速地读取或更新整个对象,可以考虑使用 JSON+String,因为这种方式可以将整个对象序列化成一个字符串,只需要一次读取或更新操作即可。具体来说,三者读取一个对象的性能数据基本等价于 “GET/SET key vs HMGET/HMSET key field [field …] vs Opt(Pipline GET/SET, MGET/MSET) key [key …]“。
总体而言,JSON+String、Multiple String 和 Hash 都是在 Redis 中存储对象的有效方式,具体使用哪种方式取决于数据的结构和应用场景。如果数据结构简单,且需要跨多个语言和平台使用,那么使用 JSON+String 可能是比较好的选择。如果需要更灵活地管理对象的属性,或者需要根据需要读取或更新对象的某些属性,那么使用多个字符串或哈希可能更适合。在实际使用中,可以根据具体的数据结构和应用场景选择最适合的方式。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/03-12-2023/how-to-store-objects-in-redis.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
积木可以帮助儿童培养创造力和空间想象力,也可以被用来帮助人们理解系统稳定性的概念。
在系统稳定性中,积木可以被视为一个模型,代表系统的组成部分。每个组成部分都会相互影响,从而影响整个系统的稳定性。就像在积木塔中,每个积木都要与其它积木相互连接,以确保整个积木塔的稳定性。如果其中一个积木被移动或摇晃,可能会导致整个积木塔崩塌。
一个系统的脆弱性取决于其在面对外部压力或内部故障时能否维持其功能或性能,影响系统脆弱的因素包括:
当资源使用率都处在低位,或者请求量保持在处理能力之下,流量再大无非面多加水,水多加面;当机器全新或在保,硬件故障频率保持在低位;环境宽松,会有一种错觉:怎么做都是对的。当需要缩容提升资源使用率,请求延迟毛刺让你无从下手;当每季度数台机器硬件故障,让你疯狂救火;就会倒逼产品在可用性方面下硬功夫。
接下来的部分以 Redis 架构来说明,技术决策是如何影响系统可用性的
在 Codis 架构下,所有的数据按照 Key 对数据进行分片,每个分片提供一部分数据的访问能力。每个 Cmd 无论 Key 数量多少,都只能请求一个分片。整体架构如下:
实际情况是,用户的一次请求所需要的数据,可能会存储在多个分片内,譬如:搜索结果页。
为了满足类似需要,国内云厂商的 Redis 提供了跨分片请求的功能,以降低复杂度、吸引用户。当一个 MGET 的多 Key 请求发送到 Proxy 之后,由 Proxy 实现多个分片的命令拆分、聚合运算。整体架构如下:
客户收获了自由,可以放飞自我。不用考虑请求跨多少分片,一次性 MGET 数百 Key 稀松平常;云厂商吸引了用户,财报靓丽。
突然有一天,有一个分片的机器降频了,处理能力大幅下降,请求量超过了分片处理能力;紧接着请求在 Proxy 大量堆积,一个分片开始超时报错;再接着业务发起大量重试,其他分片也因为重试导致带来的额外压力而积压。最终整个集群雪崩,业务整体崩溃。
聪明的朋友可能会有疑问:一个分片失败,只重试失败的分片不就可以了,为何还要重试其他已经成功的分片?
你抓住了重点,Proxy 能否支持部分失败呢?
答案是:可以。
Redis 刚开始是没有集群模式的,即使是 Redis Cluster 也是不支持跨 Slot 请求的,因此每次请求都只有两种结果:成功、失败。
完美支持“部分失败”需要依赖 RESP 协议、SDK、业务代码的支持,如此一来整体使用复杂度与自行分片请求已所差无几。
RESP:
RESP Arrays are sent using the following format:- A `*` character as the first byte, followed by the number of elements in the array as a decimal number, followed by CRLF.- An additional RESP type for every element of the Array.
SDK:
// https://github.com/redis/go-redisfunc (r *Reader) readSlice(line []byte) ([]interface{}, error) {n, err := replyLen(line)if err != nil {return nil, err}val := make([]interface{}, n)for i := 0; i < len(val); i++ {v, err := r.ReadReply()if err != nil {if err == Nil {val[i] = nilcontinue}// 正确处理if err, ok := err.(RedisError); ok {val[i] = errcontinue}return nil, err}val[i] = v}return val, nil}
业务代码:
// 定义要查询的key数组 keys := []string{"key1", "key2", "key3"} // 使用MGET命令获取多个key对应的value值 values, err := client.MGet(keys...).Result() if err != nil { return fmt.Errorf("redis request failed:%s", v) } // 输出结果 for _, val := range values { strVal, ok := v.(string) // 结果类型判断,避免类型强转导致 Panicif !ok {return fmt.Errorf("invalid redis response type:%s", v)} fmt.Printf("key:%s, value:%v\n", keys[i], strVal) }// 手动重试失败的 Key ...
在扇出的情况下,不同类型的 RPC 请求对于服务的影响巨大。Unary RPC 需要等待所有扇出请求全部返回,重组完毕才能一次性返回给主调方。
当 Proxy 接收的请求数没有变化的前提下,不同大小的 Key 数量,最终会得到不一样的扇出数。切分和重组并非是无代价的,都需要额外的计算资源,导致 Proxy 的 CPU 使用率尖峰;Proxy 请求的整体响应耗时就取决于耗时最长的扇出请求,而该扇出请求的耗时又受 Key 数量的影响。以 MGET 1000 个 Key 为例,可能会切分成:
1)1000 个 Slots,每 Cmd 1 个 Key,并发请求 1000 个 Slots 的Redis 节点
2)1 个 Slot,该 Cmd 1000 个 Key
3)10 个 Slots,每 Cmd 100 个 Key,并发请求 1 个 Redis 节点,Redis 顺序执行
首先,情况 1)的概率最大:
Redis Cluster 的固定槽位数量 “16384”,1000 有着数量级上的差距,因此在不使用 Hashtag 的情况下基本是分布在不同的 Slots。 扇出暴增,将导致 Proxy 网络IO和内存的使用量急剧增加。
其次,情况 2)请求的 Key 最终落到同一 Slot。在正常的业务情况下,每次请求的 Key 数量一般会符合正态分布,请求的数量一般分布在一定的区间。
假设请求 Key 数量的中位数为 75 个 Key,Key 数量可能会有如下分布:
Redis 服务器在处理 1% 的请求时就会出现阻塞,从而影响其他 99% 的请求延迟。整体效果如下:
根据具体情况,选择合适的批量操作方式(比如分批次获取)以及使用 Redis 的 pipelining 技术等,就可以避免阻塞得到更稳定的服务:
针对 Redis 的场景,Redis 官方博客提供了一些建议,包括:
**针对所有场景,一次用户请求响应的过程,其实就是数据读取、计算、展示的过程。请求精细划分,可以把计算从在线转移到离线;从读取转移到写入,一次计算,次次读取。简单来说,怎么存就决定了怎么取**。
读取的数据确定、展示的样式确定,计算的复杂度不会消失不见。如果只是将计算从业务系统,转移到基础架构(从北向服务转移到南向服务);从无状态服务转移到有状态服务。实现方案简单了,系统也脆弱了。
万事皆有缘由,世事岂无因果。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/03-12-2023/why-is-the-system-so-fragile.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
集群是一个分片内的所有副本不能共一台机器,如果不同分片的副本共一台机器。该部署方式会导致单机故障影响多个副本,集群可用性低,共识达成慢,故障恢复时间较长。
N 个 Master 的集群,1 个 Master 节点挂掉仍然可用的概率是:
1-(1/(N*2-1))
cross-slots query
,等同于在 Proxy 层扇出。单一分片故障的情况下,业务侧的重试会增加集群其他分片的流量,进而影响服务的稳定性。其次,由于 Redis 自身原子性的保证,目前 Redis 协议并不支持部分失败。
在限流、熔断等场景下,节点 IP 并不适合作为逻辑概念使用。其一在于会发生主从切换,其二在于硬件维护场景下节点迁移 IP 会发生变化。而 Slot 代表的是数据的逻辑概念,跟机器并不存在绑定关系。缺少 Shard 的概念,将难以基于 Shard 实现相关功能
备注:Redis 7.2 加入了 Shard 的概念,CLUSTER SLOTS 的相关命令也逐渐被 CLUSTER SHARDS 替代
当正在对 key 所属的 hash slots 进行重新分片时,多 key 操作可能变得不可用。
更具体地说,即使在重新分片期间,针对所有存在且仍哈希到同一 hash slot(重新分片的源节点或目标节点)的多 key 操作仍然可用。
对不存在的 key 或在重新分片期间被拆分到源节点和目标节点之间 key 的多 key 操作将产生 -TRYAGAIN 错误。客户端可以在一段时间后尝试该操作,或报告错误。
一旦指定 hash slot 的迁移终止,该 hash slot 的所有多 key 操作都将再次可用。
同步搬迁遇到 Big key 会阻塞 Redis 处理请求,导致请求(延迟)毛刺
因为 migrating 状态没有被同步到副本,所以当重新分片期间被拆分到源节点和目标节点之间 key 的多 key 操作,存在在另外一个节点的 key 读取到的数据为 nil
全量同步导致内存翻倍、数据复制导致 CPU 毛刺
Redis 并不适合绑定在单个 CPU 上。Redis fork 会运行 CPU 密集型的后台任务,如BGSAVE 或 BGREWRITEAF。如果 Redis 实例绑定在给定核心上,后台作业也将在同样的核心,抢占 CPU 核心,与 Redis 事件竞争 CPU 的循环。它产生了巨大的性能 Redis 实例的降级(延迟、吞吐量)
最好将 Redis 绑定到 NUMA 节点(即多个核心),并在此基础上保持至少一个核心空闲支持后台任务
数据写入量大,主从同步落后,导致频繁全量同步
在使用 Redis 复制的设置中,强烈建议在主服务器和副本中启用持久化。当这不能实现时,例如,由于磁盘速度非常慢导致的延迟问题,应配置实例以 避免 reboot 后自动重启。
为了更好地理解为什么将持久化关闭的 master 配置为自动重新启动是危险的,请防止以下故障模式,即从 master 及其所有副本中擦除数据:
- 有一个设置,节点 A 充当 master 节点,持久化被关闭,节点 B 和 C 从节点 A 复制。
- 节点 A 崩溃,但它有配置自动重启可以重启进程。但是,由于持久化已关闭,节点将以空数据集重新启动。
- 节点 B 和 C 将从空的节点 A 复制,因此它们将实际上销毁已有的数据副本。
当 Redis Sentinel 用于高可用性时,关闭主机上的持久化,并且配置自动重启进程,是危险的。例如,Master 节点可能快速重启,使 Sentinel 无法检测到故障,从而出现上述故障模式。
默认情况下,副本将忽略 “maxmemory”(除非在故障切换后或手动将其升级为 Master 副本)。副本不会主动淘汰数据,而会等待 Master 的 DEL 命令。最终可能会使用比 maxmemory 设置更多的内存(因为复制副本上有某些缓冲区可能更大,或者数据结构有时会占用更多内存等等)。要更改此行为,可以允许复制副本不忽略最大内存。要使用的配置指令是:
replica-ignore-maxmemory no
执行手动故障切换时,连接到 Master 节点的客户端都会被停止(延迟毛刺)。同时,Master 将复制偏移量发送给副本,副本等待该侧的偏移量到达。
当达到复制偏移量时,故障切换将启动,并通知旧 Master 有关配置切换的信息。旧 Master 取消阻止客户端时,它们将重定向到新 Master。
当副本想要成为 Master 时,没有分配 slots 的 Master 不参与选举过程。
Redis 环境中的灾难恢复与备份基本相同,而且能够在许多不同的外部数据中心传输这些备份。即使在某些灾难性事件影响到运行 Redis 并生成其快照的主数据中心的情况下,也可以通过这种方式保护数据。
- 备份 RDB
- RPO:小时级
- RTO:小时级
- 备份 AOF(Redis 7.0.0 以上)
- RPO:分钟级
- RTO:分钟级
RPO 和 RTO 较高
异构 DR 实现难度高
搬迁数据只能顺序进行,速度慢。
迁移状态不会广播给从节点,迁移过程中发生 failover,新的 master 迁移状态会丢失,需要重新设置
因为 SET SLOT 是先生效再共识,迁移过程 SET SLOT 的操作需要严格有序,先设置 Dst Node 确认生效再设置 Src Node。
移除节点需要在 1 分钟内通知所有节点,该节点下线。否则节点会可能再次加入集群
Prometheus 一般 间隔 通过 Redis Exporter 调用 INFO 命令拉取 Redis 监控数据。有些指标是瞬时值,可能不会被记录下来。 例如 Loading 状态
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/02-14-2023/details-about-redis-cluster.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
随着在 Kubernetes 场景打磨下不断成长, etcd 逐渐成为技术圈众所周知的开源产品。
etcd 因其丰富的功能,并被越来越多的选择,甚至于被当作 “银弹” 过度使用。本文的重点在于了解其发展历程、实现细节,并针对技术方案选型给出自己的理解。
本文所有内容基于 etcd v3.5.0
2013 年,有一个叫 CoreOS 的创业团队,需要一个协调服务来存储服务配置信息、提供分布式锁等能力,来构建一款叫做 Container Linux 的产品。当分析过需求场景、痛点和核心目标,并评估社区开源的选项之后,CoreOS 团队最终选择自己造轮子,从 0 到 1 开发 etcd 以满足其需求。
架构说明
架构说明
步骤:
从上面的流程可以看出,一条记录首先是写入本地的 raftlog。然后发送给其它节点,当超过半数的节点接收到这条记录时,那么该记录就被认为已经 commit 了。最后才能被 KVServer apply。 所以下面的条件永远成立:
ApplyId <= CommitId <= RaftLogId
etcd 使用 Raft 协议来维护集群内各个节点状态的一致性。每个 etcd 节点都维护了一个状态机,并且任意时刻至多存在一个有效的主节点。主节点处理所有来自客户端写操作,通过 Raft 协议保证写操作对状态机的改动会可靠的同步到其他节点。
步骤:
创建租约:
附加租约:
淘汰租约:
注意:
租约影响性能因素源自多方面:
从实际使用场景上来,为了降低 Lease TTL 过期带来的影响,可以将 Lease 与 Key 独立开,由系统自行控制和判定存活状态和 Key 的删除。
为了降低 etcd server 的压力可以把多个 kv 关联在一个 lease 上的,比如:
kubernetes 场景中有大量的 event,如果一个 event 一个 Lease, Lease 数量是非常多的,Lease 过期会触发大量写请求,再加上变更通知产生的读请求,对 etcd server 压力非常大。
为了解决这个问题对 etcd server 性能的影响,Lease 过期淘汰会默认限速每秒 1000 个。因此 kubernetes 场景为了优化 Lease 数,会将最近一分钟内产生的 event key 列表,复用在同一个 Lease,大大降低了 Lease 数。
概念:
etcd 通过对 watcher 进行分类,来实现事件的可靠性:
订阅流程:
当 Client 发起一个 watch key 请求的时候,etcd 的 WatchServer 收到 watch 请求后,会创建一个 serverWatchStream, 它负责接收 client 的 gRPC Stream 的 create/cancel watcher 请求 (recvLoop goroutine),并将从 MVCC 模块接收的 Watch 事件转发给 client(sendLoop goroutine)。
当 serverWatchStream 收到 create watcher 请求后,serverWatchStream 会调用 MVCC 模块的 WatchStream 子模块分配一个 watcher id,并将 watcher 注册到 MVCC 的 WatchableKV 模块。
etcd 启动后,WatchableKV 模块会运行 syncWatchersLoop 和 syncVictimsLoop goroutine,分别负责不同场景下的事件推送。
etcd 使用 map 记录了监听单个 key 的 watcher,但是你要注意的是 Watch 特性不仅仅可以监听单 key,它还可以指定监听 key 范围、key 前缀,因此 etcd 还使用了区间树。当收到创建 watcher 请求的时候,它会把 watcher 监听的 key 范围插入到上面的区间树中,区间的值保存了监听同样 key 范围的 watcher 集合 /watcherSet。
当产生一个事件时,etcd 首先需要从 map 查找是否有 watcher 监听了单 key,其次它还需要从区间树找出与此 key 相交的所有区间,然后从区间的值获取监听的 watcher 集合。区间树支持快速查找一个 key 是否在某个区间内,时间复杂度 O(LogN),因此 etcd 基于 map 和区间树实现了 watcher 与事件快速匹配,具备良好的扩展性。
推送流程:
注意:
若 watcher 监听的版本号已经小于当前 etcd server 压缩的版本号,历史变更数据就可能
已丢失,因此 etcd server 会返回 ErrCompacted 错误给 client。client 收到此错误后,需重新获取数据最新版本号后,再次 Watch。在业务开发过程中,使用 Watch API 最常见的一个错误之一就是未处理此错误。
其次,Watch 返回的 WatchChan
有可能在运行过程中失败而关闭,此时 WatchResponse.Canceled
会被置为 true
,WatchResponse.Err()
也会返回具体的错误信息。所以在 range WatchChan 的时候,每一次循环都要检查 WatchResponse.Canceled
,在关闭的时候重新发起 Watch 或报错。
方案选型可以从业务系统的需求和 etcd 的特性、性能,两个方面着手。
先看使用 etcd 提供服务的目标系统。如果你正在深入 Kubernetes 或开始使用服务网格,您可能会遇到术语“控制平面(control plane)”和“数据平面(data plane)”。术语 “控制平面” 和 “数据平面” 都是关于关注点的分离,即系统内职责的明确分离。控制平面是一切与策略建立和下发有关的部分,而数据平面是一切与执行策略有关的部分。当控制平面出现故障,只会影响新的策略变更变更,但不会影响已有策略执行,即,数据平面的功能。
以 Kubernetes 为例,其核心服务包括:
组件 | 描述 |
---|---|
kube-apiserver | 提供了资源的唯一入口,并提供认证、授权、访问控制、API 注册和发现等 |
kube-scheduler | 负责资源的调度,按照预定的调度策略将 Pod 调度到相应的机器上 |
kube-controller-manager | 负责维护集群的状态,比如故障检测、自动扩展、滚动更新等 |
etcd | 存储整个集群的状态 |
kube-proxy | 负责为 Service 提供 cluster 内部的服务发现和负载均衡 |
以上服务故障,并不会影响当前已有 Pod 正常对外提供服务。
再看 etcd 本身。要了解 etcd 适用的场景,质量最高的来源是其官网。
介绍:
“etcd” 名字来源于两个想法:unix “/etc” 文件夹和 分布式( “d”istributed)系统。“/etc” 文件夹是存储单个系统的配置数据的地方,而 etcd 存储大规模分布式系统的配置信息。因此,“d”istributed “/etc” 是 “etcd”。etcd 被设计为大规模分布式系统的通用基座。这类系统永远不容忍裂脑操作,并愿意牺牲可用性来实现该目标。
分布式系统使用 etcd 用于配置管理、服务发现和协调分布式工作。etcd 的常见分布式模式包括领导者选举、分布式锁和监控机器活动。
使用场景:
- CoreOS 的 Container Linux:在 Container Linux 上运行的应用程序可以获得自动、零停机的 Linux 内核更新。Container Linux 使用 Locksmith 来协调更新。Locksmith 在 etcd 上实现了分布式信号量,以确保在任何给定时间只有集群的一个子集在重新启动。
- Kubernetes 将配置数据存储到 etcd 中,用于服务发现和集群管理;etcd 的一致性对于正确调度和操作服务至关重要。Kubernetes API 服务器将集群状态持久化为 etcd。它使用 etcd 的 watch API 来监视集群并生效关键的配置变更。( 2016 年 Kubernetes 1.6 发布,默认启用 etcd v3,助力 Kubernetes 支撑 5000 节点集群规模)
其他:
- 最大可靠数据库大小: 数 GB
- 因为缺少数据分片,复制无法水平扩展
- 租约提供了一种用于减少中止请求数量的优化机制。
从基本介绍以及使用场景来看,etcd 的定位在于存储数据量小、更新频率低的数据,用于一致性要求高于可用性、无需水平扩展的场景。
以 超大型集群 为例,一个超大型型集群服务的客户端超过 1500 个,每秒请求超过 10000 个,存储数据超过 1 GB。
云厂商 | 机型 | CPU | 内存 (GB) | 最大并发 IOPS | 磁盘带宽 (MB/s) |
---|---|---|---|---|---|
AWS | m4.4xlarge | 16 | 64 | 16,000 | 250 |
GCE | n1-standard-16 + 500GB PD SSD | 16 | 60 | 15,000 | 250 |
压测的硬件配置:
- Google Cloud Compute Engine
- 3 machines of 8 vCPUs + 16GB Memory + 50GB SSD
- 1 machine(client) of 16 vCPUs + 30GB Memory + 50GB SSD
- Ubuntu 17.04
- etcd 3.2.0, go 1.8.3
写性能
Key 数量 | Key 大小 (byte) | Value 大小 (byte) | 连接数 | Client 数 | 目标 etcd server | 平均写 QPS | 平均请求延迟 | 平均服务 RSS |
---|---|---|---|---|---|---|---|---|
10,000 | 8 | 256 | 1 | 1 | leader only | 583 | 1.6ms | 48 MB |
100,000 | 8 | 256 | 100 | 1000 | leader only | 44,341 | 22ms | 124MB |
100,000 | 8 | 256 | 100 | 1000 | all members | 50,104 | 20ms | 126MB |
读性能
请求数 | Key 大小 (byte) | Value 大小 (byte) | 连接数 | Client 数 | 一致性 | 平均读 QPS | 平均请求延迟 |
---|---|---|---|---|---|---|---|
10,000 | 8 | 256 | 1 | 1 | Linearizable | 1,353 | 0.7ms |
10,000 | 8 | 256 | 1 | 1 | Serializable | 2,909 | 0.3ms |
100,000 | 8 | 256 | 100 | 1000 | Linearizable | 141,578 | 5.5ms |
100,000 | 8 | 256 | 100 | 1000 | Serializable | 185,758 | 2.2ms |
一般 etcd 的集群为 3 或 5 个节点,Key 数量为 10w~ 规模下,预估集群性能如下:
综合目标系统和 etcd 本身的细节来看:
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/01-27-2023/etcd-implement-and-tech-selection.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
译者序
本文发表于 2016 年,作者为 Borg、Omega 和 Kubernetes 的主要开发: Brendan Burns 和 David Oppenheimer, 其他相关论文包括:
文章总结了云原生下的多种设计模式,能够对如何设计分布式系统有所启发。从本论文中你也可以看到容器管理系统 ( Kubernetes )、Service Mesh (Istio)、 监控系统 ( Prometheus ) 等诸多明星系统的影子,进而推测未来云原生领域的发展方向。
在 1980 年代末和 1990 年代初,面向对象编程彻底改变了软件开发,普及了将应用构建为模块化组件集合的方法。 今天,我们在分布式系统开发中看到了类似的革命,基于容器化软件组件构建的微服务架构越来越受欢迎。 因为容器之间的隔离优势,容器 [15] [22] [1] [2] 特别适合作为分布式系统中的基本“对象”。 随着这种架构风格的成熟,我们看到了设计模式的出现,就跟面向对象程序所做的一个道理——以对象(或容器)的方式思考抽象掉代码的低级细节,最终揭示各种应用和算法共有的高级模式。
本文描述了我们在基于容器的分布式系统观察到的三种设计模式:容器管理的单容器模式、紧密协作容器的单节点模式和分布式算法的多节点模式。 与之前的面向对象模式一样,分布式计算的这些模式实现了最佳实践,简化了开发,让使用它们的系统更可靠。
在使用面向对象编程多年之后,设计模式出现并被记录了下来[3]。 这些模式编码和规范化了解决特别常见编程问题的一般方法。 这种编码进一步提高了编程的总体水平,因为它使经验不足的程序员更容易产出高质量的代码;同时,它促进了可重用库的发展,使代码更可靠,开发速度更快。
当今分布式系统工程的最新技术看起来更像是 1980 年代早期的编程时期,而不是面向对象开发的时期。 然而,从 MapReduce 模式 [4] 将 “大数据” 编程的力量带到广阔的领域和开发者群体的成功中可以清楚地看出,建立正确的模式集可以显着提高分布式系统编程的质量、速度和可达性。 但即使 MapReduce 的成功很大程度上也仅限于单一的编程语言,因为 Apache Hadoop [5] 生态系统主要是用 Java 编写的。 为分布式系统设计开发一套真正完备的模式需要一个非常通用的、语言中立的工具来表示系统的原子元素。
值得庆幸的是,过去两年 Linux 容器技术的采用率急剧上升。容器和容器镜像正是分布式系统模式开发所需的抽象。到目前为止,容器和容器图像仅通过作为一种更好、更可靠的方法从开发到生产交付软件,就获得了广泛的应用。通过紧密的封装,依赖自治,并提供原子部署标记(“成功”/“失败”),它们极大地提升了以前在数据中心或云中部署软件的最先进技术的水平。但容器有可能不止于此——我们相信它们注定会类似于面向对象的软件系统中的对象,将使分布式系统设计模式的发展成为可能。在下面的部分中,我们解释了为什么我们认为必然如此,并描述了我们看到的一些模式,这些模式将在未来几年中规范和指导分布式系统的工程。
与对象边界 ( boundary) 非常相似, 容器为定义接口提供了一个自然的边界 ( boundary) 。容器不仅可以通过此接口暴露应用特定的功能,还可以为管理系统暴露钩子 (hooks)。
传统的容器管理接口非常有限。容器有效地暴露三个动词:run() pause() 和 stop()。虽然此接口很有用,但更丰富的接口可以为系统开发和运维人员提供更多能力。鉴于几乎所有现代编程语言都普遍支持 HTTP Web 服务器,并且对 JSON 等数据格式的广泛支持,因此很容易定义一个基于 HTTP的管理 API,除了其主要功能之外,还可以通过让容器在特定端点 (endpoints) 托管 Web 服务器来“实现”其他功能。
在北向方面,容器可以公开一组丰富的应用信息,包括应用特定的监控指标(QPS、应用健康状况等)、开发者感兴趣的分析 (profiling) 信息(线程、堆栈、锁争用、网络消息统计信息) 等)、组件配置信息和组件日志。 作为此的实际例子,Kubernetes [6]、Aurora [7]、Marathon [8] 和其他容器管理系统允许用户通过特定的 HTTP 端点 ( endpoints )(例如 “/health”)定义健康检查。 对我们前面所描述的之外其他元素,北向 API 的标准化支持更为罕见。
在南向方面,容器接口提供了一个自然之选来定义生命周期,这使得编写受管理系统控制的软件组件变得更加容易。例如,集群管理系统通常会为任务分配“优先级”,即使集群超额订阅,高优先级任务也能保证运行。这种保证是通过驱逐已运行中的低优先级任务来实现的,低优先级任务将不得不等待资源可用再执行。驱逐可以通过简单地杀死优先级较低的任务来实现,但这会给开发人员带来不必要的负担,让他们应对代码中任意死亡的情况。相反,如果在应用和管理系统之间定义了一个规范的生命周期,遵从定义的契约以后,应用组件将变得更易于管理;同时,开发人员依赖契约以后,系统的开发变得更容易。例如,Kubernetes 使用 Docker 的“优雅删除”功能,通过 SIGTERM 信号警告容器它将被终止,然后在应用定义的时间窗口之后再发送 SIGKILL 信号。这允许应用完成运行中的操作、将状态刷新到磁盘等再干净地终止。可以想象扩展该机制以提供对状态序列化和恢复的支持,从而使有状态分布式系统的状态管理变得更加容易。
考虑一个更复杂生命周期的例子,Android Activity 模型 [9],它支持一系列回调(例如 onCreate()、onStart()、onStop() 等)和一个规范定义的系统如何触发回调的状态机。如果没有这个规范的生命周期,很难开发健壮、可靠的 Android 应用。 在基于容器的系统的上下文中,泛化为应用定义的在创建容器时、启动时、终止前等调用的钩子 (hooks)。另一个容器可能支持的南向 API 的例子是“复制 (replicate) 自己”(以横向扩容服务)。
除了单个容器的接口之外,我们还看到了跨容器设计模式的出现。 我们先前确定了几种这样的模式 [10]。单节点模式由共同调度到单个主机上的共生容器组成。 容器管理系统支持将多个容器作为一个原子单元共同调度,抽象 Kubernetes 称为 “Pods”,Nomad [11] 称为“任务组”,这是启用我们在本节中描述的模式所必需的特性。
多容器部署的第一种也是最常见的模式是边车模式。 边车容器扩展并增强了主容器。 例如,主容器可能是一个 Web 服务器,它可能与一个“logsaver” 边车容器配对,后者从本地磁盘收集 Web 服务器的日志并将它们流式传输到集群存储系统。 图 1 是边车模式的示例。 另一个常见例子是 Web 服务器,它从本地磁盘内容提供服务,该内容由边车容器填充,该容器定期同步来自 git 存储库、内容管理系统或其他数据源的内容。 这两个例子在谷歌都很常见。 边车模式之所以是可能的,是因为同一台机器上的容器可以共享本地磁盘卷。
虽然总是可以将边车容器的功能构建到主容器中,但使用单独的容器有几个好处。
请注意,这五个好处适用于所有我们在本文其余部分描述的容器模式。
我们观察到的下一个模式是特使模式。 特使容器代理与主容器之间的通信。例如,开发人员可能会将使用 memcache 协议的应用与 twemproxy 特使配对。该应用认为它只是与本地主机上的单个内存缓存进行通信,但实际上 twemproxy 正在将请求分片到其他位置的集群中分布式安装的多个内存缓存节点。这种容器模式在三个方面简化了程序员的生活:
特使之所以是可能的,因为同一台机器上的容器共享相同的本地主机网络接口。图 2 展示了这种模式的例子。
我们观察到的最后一个单节点模式是适配器模式。与向应用呈现简化的外部世界视图的特使模式相比,适配器向外部世界呈现简化、统一的应用视图。它们通过标准化跨多个容器的输出和接口来做到这一点。适配器模式的一个实际例子是,确保系统中所有容器具有相同监控接口的适配器。当今的应用使用多种方法导出其指标(例如 JMX、statsd 等)。但是,如果所有应用都呈现一致的监控接口,那么单个监控工具就更容易从一组异构应用中收集、聚合和呈现指标。在谷歌内部,我们通过编码约定实现了这一点,但这只有在您从头开始构建软件时才有可能。适配器模式使遗留和开源应用的异构世界,无需修改原始应用,就能够呈现统一的接口。主容器可以通过 localhost 或共享本地卷与适配器通信。如图 3 所示。请注意,虽然一些现有的监控解决方案能够与多种类型的后端进行通信,但它们在监控系统自身使用应用特定的代码,关注点分离 ( Separation of Concerns,SoC ) 是模糊的。
超越单台机器上的协作容器,模块化容器更容易构建协调一致的多节点、分布式应用。 接下来我们将描述其中的三种分布式系统模式。 与上一节中的模式一样,这些模式也需要系统支持 Pod 抽象。
分布式系统中最常见的问题之一是领导者选举(例如 [20])。虽然复制通常用于在一个组件的多个相同实例之间共享负载,但复制的另一个更复杂的用途是应用需要将一个副本从一组副本中区分为“领导者”。如果领导者失败,其他副本可以快速取代领导者。一个系统甚至可以并行运行多个领导者选举,例如:确定多个分片中每个分片的领导者。许多库可以执行领导者选举。它们通常很难正确理解和使用,此外,它们受到特定的实现编程语言的限制。将领导选举库链接到应用的替代方案是使用领导者选举容器。每个领导者选举容器都与需要领导者选举的应用实例共同调度,一组领导者选举容器,可以在它们之间执行选举,并且它们可以通过 localhost 向每个需要领导者选举的应用容器提供简化的 HTTP API(例如 becomeLeader、renewLeadership 等)。这些领导者选举容器可以由该复杂领域的专家构建一次,然后应用开发人员可以重复使用随后的简化接口,而不管他们选择什么实现语言。这代表了软件工程中最好的抽象和封装。
尽管工作队列(如领导者选举一样)是一个经过充分研究的主题,许多框架都实现了它们,但它们也是可以从面向容器的体系结构中受益的分布式系统模式的例子。在以前的系统中,框架将程序限制在单一语言环境中(例如 Celery for Python [13]。译注:异步任务队列),或者工作和二进制文件的分发交由实现者处理(例如 Condor [21]。译注:作业调度系统)。容器实现 run() 和 mount() 接口的可能性,使得实现通用工作队列框架变得相当简单,该框架可以利用打包了任意处理代码的容器和任意数据,构建一个完整的工作队列系统。开发人员只需构建一个容器,该容器可以在文件系统上接收输入数据文件,并将其转换为输出文件;这个容器将成为工作队列的一个阶段。开发完整工作队列所涉及的所有其他工作都可以由通用工作队列框架处理,无论何时需要类似的系统都可以重用该框架。用户代码集成到此共享工作队列框架中的方式如图 4 所示。
我们着重突出的最后一个分布式系统模式是分散/汇聚。 在这样的系统中,外部客户端向 “根” 或 “父” 节点发送初始请求。 此根节点将请求分散到大量服务器以并行执行计算。 每个分片返回部分数据,根节点将这些数据汇聚到原始请求的单个响应中。 这种模式在搜索引擎中很常见。 开发这样一个分布式系统涉及大量样板代码:分发请求、收集响应、与客户端交互等。大部分代码都是非常通用的,同样,就像在面向对象编程中一样,可以通过这样一种方式重构,即可以提供单个实现,只要它们实现特定的接口,就可以与任意容器一起使用。 特别地,为了实现分散/汇聚系统,用户需要提供两个容器。
通过提供实现这些相对简单的接口的容器,很容易看出用户如何实现任意深度的散布/汇聚系统(如果需要,除了根之外,还包括父级)。这样的系统如图 5 所示。
面向服务的架构 (SOA) [16] 早于基于容器的分布式系统,并与其许多特征相通。 例如,两者都强调可重用的组件,这些组件具有通过网络进行通信的定义明确的接口。 另一方面,SOA 系统中的组件往往比我们描述的多容器模式粒度更大,耦合更松散。 此外,SOA 中的组件通常实现业务活动,而我们在这里关注的组件更类似于通用库,可以更轻松地构建分布式系统。 最近出现了术语 “微服务” 来描述我们在本文中讨论的组件类型。
网络组件的标准化管理接口的概念至少可以追溯到 SNMP [19]。 SNMP 主要侧重于管理硬件组件,尚未出现管理基于微服务/容器的系统的标准。 这并没有阻止众多容器管理系统的发展,包括 Aurora [7]、ECS [17]、Docker Swarm [18]、Kubernetes [6]、Marathon [8] 和 Nomad [11]。
我们在第 5 节中提到的所有分布式算法都有悠久的历史。 人们可以在 Github 中找到许多领导者选举实现,尽管它们似乎是作为库而不是独立组件构建的。 有许多流行的工作队列实现,包括 Celery [13] 和 Amazon SQS [14]。 分散-汇聚已被识别为一种企业集成模式 [12]。
正如面向对象编程引出了面向对象的“设计模式”的出现和规范化一样,我们看到容器体系结构引出了基于容器的分布式系统的设计模式。在本文中,我们识别了我们看到的三种类型的模式:系统管理的单容器模式、紧密协作容器的单节点模式和分布式算法的多节点模式。在所有情况下,容器都提供了许多与面向对象系统中的对象相同的好处,例如可以很容易地在多个团队之间划分实现,并在新的上下文中重用组件。此外,它们还提供了一些分布式系统独有的好处,例如使组件能够独立升级、多种语言编写,以及使整个系统能够优雅地降级。就像几十年前面向对象编程一样,我们相信,容器模式集只会增长,并且在未来几年,通过实现分布式系统开发的标准化和规范化,它们将彻底改变分布式系统编程。
[1] Docker Engine http://www.docker.com
[2] rkt: a security-minded standards-based container engine https://coreos.com/rkt/
[3] Erich Gamma, John Vlissides, Ralph Johnson, Richard Helm, Design Patterns: Elements of Reusable Object-Oriented Software, AddisonWesley, Massachusetts, 1994.
[4] Jeffrey Dean, Sanjay Ghemawat, MapReduce: Simplified Data Processing on Large Clusters, Sixth Symposium on Operating System Design and Implementation, San Francisco, CA 2004.
[5] Apache Hadoop, http://hadoop.apache.org
[6] Kubernetes, http://kubernetes.io
[7] Apache Aurora, https://aurora.apache.org.
[8] Marathon: A cluster-wide init and control system for services, https://mesosphere.github.io/marathon/
[9] Managing the Activity Lifecycle, http://developer.android.com/training/basics/activitylifecycle/index.html
[10] Brendan Burns, The Distributed System ToolKit: Patterns for Composite Containers, http://blog.kubernetes.io/2015/06/the-distributedsystem-toolkit-patterns.html
[11] Nomad by Hashicorp, https://www.nomadproject.io/
[12] Gregor Hohpe, Enterprise Integration Patterns, Addison-Wesley, Massachusetts, 2004.
[13] Celery: Distributed Task Queue, http://www.celeryproject.org/
[14] Amazon Simple Queue Service, https://aws.amazon.com/sqs/
[15] https://www.kernel.org/doc/Documentation/cgroupv1/cgroups.txt
[16] Service Oriented Architecture, https://en.wikipedia.org/wiki/Serviceoriented architecture
[17] Amazon EC2 Container Service, https://aws.amazon.com/ecs/
[18] Docker Swarm https://docker.com/swarm
[19] J. Case, M. Fedor, M. Schoffstall, J. Davin, A Simple Network Management Protocol (SNMP), https://www.ietf.org/rfc/rfc1157.txt, 1990.
[20] R. G. Gallager, P. A. Humblet, P. M. Spira, A distributed algorithm for minimum-weight spanning trees, ACM Transactions on Programming Languages and Systems, January, 1983.
[21] M.J. Litzkow, M. Livny, M. W. Mutka, Condor: a hunter of idle workstations, IEEE Distributed Computing Systems, 1988.
[22] https://linuxcontainers.org/
来源:Design Patterns for Container-based Distributed Systems
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/11-13-2022/design-patterns-for-container-based-distributed-systems-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
译者序
本文为 Bazel 依赖管理的文章,介绍了大规模下依赖关系的复杂情况及其应对策略。从本文可以学到什么?
在浏览前面的页面时,有一个主题反复提及:管理自己的代码相当简单,但管理其依赖关系则困难得多。存在各种各样的依赖关系:有时依赖于某个任务(如“将版本标记为完成之前推送文档”);有时依赖于某个制品(如“需要最新版本计算机视觉库才能构建代码);有时,内部依赖于代码库的另一部分,并且有时外部依赖于其他团队(无论是组织内还是第三方)的代码或数据。但无论如何,“欲此先彼”的观念在构建系统的设计中反复出现,管理依赖关系或许是构建系统最基本的工作
使用基于制品的构建系统(如Bazel)的项目被分解为一组模块,模块通过 BUILD
文件表示彼此之间的依赖关系。正确组织模块和依赖关系会对构建系统的性能以及维护工作量产生巨大影响。
在组织基于工件的构建时,第一个问题是确定单个模块应该包含多少功能。在 Bazel 中,模块由描述说明可构建单元(如 java_library 或 go_binary)的目标表示。 一种极端情况下,整个项目可以包含在一个模块中,方法是将一个 BUILD 文件放在根目录下,并以递归方式将该项目的所有源文件合并在一起。另外一种极端情况下,几乎每个源文件都可以放到自己的模块中,实质上要求每个文件在 BUILD 文件中列出它所依赖的其他所有文件
大多数项目都处于极端情况之间,选择涉及性能和可维护性之间的权衡。整个项目使用一个模块意味着,除了从外部添加依赖项之外,再不需要更改 BUILD 文件,但构建系统必须始终一次性构建整个项目。这意味着它无法将各部分并行或分布式构建,也无法缓存已构建的部分。每个文件一个模块则相反:构建系统在构建的缓存和调度步骤方面具有最大的灵活性,但每当更改文件引用文件时,工程师需要花费更多精力来维护依赖项列表。
尽管确切的粒度因语言而异(甚至在语言内也是如此),但相比基于任务的构建系统中编写的典型的模块,Google 倾向于使用小得多的模块。Google 的典型生产二进制文件通常依赖于数万个目标,即使是中等规模的团队也可以在其代码库中拥有数百个目标。对于具有强大内置的打包概念的语言(如 Java),每个目录通常包含一个软件包、目标和 BUILD 文件(Pants,另一个基于 Bazel 的构建系统,称之为 1:1:1 规则)。打包概念较弱的语言,每个 BUILD 文件通常会定义多个目标。
较小的构建目标的好处在大规模时开始显现出来,因为它们可以加快分布式构建的速度,减少重建目标的频率。 测试入场后,优势变得更加引人注目,因为更细粒度的目标意味着构建系统可以更智能地运行可能受给定更改影响的有限测试子集。由于 Google 认为使用较小的目标具有系统方面的优势,因此我们通过投资自动管理 BUILD 文件的工具,以避免给开发人员带来负担,从而在减轻不利影响方面迈出了一大步。
其中一些工具,如 buildizer
和 buildozer
,可以放在 buildtools 目录中与 Bazel 一起使用
Bazel 和其他构建系统允许每个目标指定可见性:一种指定哪些目标可以依赖于它的属性。目标可以是公共的,此时,工作区中的任何其他目标都可以引用它;私有的,此时,只允许同一个 BUILD 文件中引用它;或仅对明确定义的其他目标列表可见。可见性本质上与依赖相反:如果目标 A 想要依赖目标 B,则目标 B 必须使其自身对目标 A 可见。与大多数编程语言一样,通常最好尽可能降低可见性。一般来说,仅当目标代表 Google 的任何团队都可以广泛使用的库时,Google 团队才会公开。要求在使用他们代码之前与他们协调的团队,会维护一份允许的客户目标列表,作为其目标的可见范围。每个团队内部实现的目标将可见性仅限于团队拥有的目录,大多数BUILD 文件只有一个非私有的目标。
模块需要能够相互引用。将代码库拆分成精细的模块的缺点是,需要管理模块之间的依赖关系(尽管工具可以帮助自动执行)。表达依赖关系通常最终成为 BUILD 文件中的大部分内容。
在分解为精细模块的大型项目中,大多数依赖项可能是内部依赖项;即,在同一源代码库中定义和构建的另一个目标。内部依赖项与外部依赖项的不同之处在于,它们是从源代码构建的,而不是在运行构建时以预构建制品下载的。这也意味着内部依赖项没有“版本”概念,目标及其所有内部依赖项始终在存储库中的同一提交/修订时构建。关于内部依赖项,如何处理可传递依赖项(图 1)是一个应谨慎处理的问题。假设目标 A 依赖于目标 B,而目标 B 依赖于通用库目标 C。目标 A 是否能够使用目标 C 中定义的类?
图 1. 可传递依赖项
就底层工具而言,这么做没有任何问题; B 和 C 都会在构建时链接到目标 A,因此 C 中定义的任何符号都是已知的。Bazel 多年来一直允许这种情况出现,但随着 Google 不断发展,我们看到了一些问题。假设 B 已重构,使其不再需要依赖于 C。如果 B 对 C 的依赖被移除,那么通过 B 的依赖关系使用 C 的 A 以及其他所有目标都会破坏。实际上,目标的依赖项会成为其公共合约的一部分,永远无法安全更改。这意味着,依赖关系会随着时间的推移而积累,Google 的构建速度会开始变慢。
Google 最终在 Bazel 中引入了“严格可传递依赖关系模式”,从而解决了此问题。在此模式下,Bazel 会检测目标是否试图直接引用符号,而不依赖于它;如果是的话,则失败,并显示错误以及一条可用于自动插入依赖项的 shell 命令。在 Google 的整个代码库中推广这一变化,并重构数百万个构建目标,以明确列出它们的依赖项,该项目花费了多年的努力,但非常值得。由于目标中不必要依赖项减少,现在构建要快得多。而且,工程师有权删除他们不需要的依赖项,而不用担心破坏依赖它们的目标。
与往常一样,强制执行严格的可传递依赖关系需要做出权衡。因为现在经常使用的库需要在许多位置显式列出,而不是被意外地拉取,使得构建文件更详细;而工程师需要花费更多精力在 BUILD 文件中添加依赖项。此后,我们开发了相关工具,可在不进行任何开发者干预的情况下,自动检测许多缺失的依赖项并将其添加到 BUILD 文件,从而减少此类繁重工作。但即使没有此类工具,我们也发现,在代码库扩大规模的情况下这样做非常值得:显式地将依赖项添加到构建文件是一次性的成本,但只要构建目标存在,处理隐式可传递依赖关系就会导致持续的问题。默认情况下,Bazel 会在 Java 代码中强制执行严格可传递依赖关系
如果依赖项不是内部依赖项,它一定是外部依赖项。外部依赖项是指在构建系统之外构建和存储的制品。系统直接从制品库(通常通过互联网访问)导入依赖项,并按原样使用,而不是从源代码构建。外部依赖项与内部依赖项之间的最大差异之一是,外部依赖项有版本,并且版本独立于项目的源代码。
构建系统可以手动或自动管理外部依赖项的版本。手动管理时,构建文件会明确列出要从制品库下载的版本,通常使用 1.1.4 等语义版本字符串。自动管理时,源文件会指定可接受版本的范围,而构建系统始终会下载最新版本。例如,Gradle 将依赖项版本声明为“1.+”,以指定依赖项的主版本或补丁版本可以接受,前提是主版本为 1。
对小型项目来说,自动管理依赖项很方便,但它们通常是非一般规模的项目或由多个工程师处理的项目的灾难。自动管理依赖项的问题在于,无法控制版本更新。无法保证外部一方不会进行中断性的更新(即使他们声称使用语义化版本),因此,某一天工作过的构建版本可能会在第二天就被破坏,并且没有简单的方法来检测更改的内容或将其回滚到工作状态。即使构建不会中断,也可能出现无法跟踪的细微的行为或性能变化。
相比之下,手动管理的依赖项需要更新到源代码控制系统,可以轻松地找到和回滚这些依赖项,并且可以签出旧版代码库以使用旧版依赖项构建。Bazel 要求手动指定所有依赖项的版本。即使在中等规模下,手动版本管理的开销也非常值得,因为这样可以获得稳定性。
库的不同版本通常由不同的制品表示,因此理论上讲,没有理由不能在构建系统中以不同的名称声明同一外部依赖项的不同版本。这样,每个目标就都可以选择要使用的依赖项版本。这会导致实践中遇到许多问题,因此 Google 对代码库中的所有第三方依赖项强制执行严格的单一版本规则。
允许多个版本的最大问题是钻石依赖性问题。假设目标 A 依赖于目标 B 以及外部库的 v1。如果后续重构目标 B,添加对同一外部库的 v2 的依赖项,则目标 A 会中断,因为它现在隐式依赖于同一库的两个不同版本。实际上,添加新的从目标到具有多个版本的任何第三方库的依赖关系的做法,从来都不是安全的,因为该目标的任何用户都可能已经依赖于不同的版本。遵循单一版本规则可以避免该冲突。如果目标添加对第三方库的依赖关系,现存所有依赖关系已经采用相同的版本,因此可以和谐共存。
处理外部依赖项的可传递依赖关系特别困难。许多制品库(如:Maven、Central)允许制品指定仓库中特定版本的其他制品的依赖关系。默认情况下,Maven 或 Gradle 等构建工具通常以递归方式下载每个可传递依赖关系,意味着在项目中添加单个依赖项可能会导致总共下载数十个制品。
这样非常方便:添加一个新库的依赖项时,必须跟踪该库的每个传递依赖关系,并手动添加所有依赖关系,是一件非常痛苦的事。但也存在一个巨大的缺点:由于不同的库可以依赖于同一第三方库的不同版本,因此必然会违反单一版本规则,导致钻石依赖关系问题。如果目标依赖的两个外部库使用相同依赖项的不同版本,则无法确定具体会获取哪个库。也意味着,如果新版本开始拉取它的某些依赖项的冲突版本,则可能会导致整个代码库中看似不相关的故障。
因此,Bazel 不会自动下载传递依赖项。然而,并没有万能的办法,Bazel 的替代方案是,使用全局文件列出代码库的每个外部依赖项以及用于整个代码库的相应依赖项的显式版本。幸运的是,Bazel 提供的工具能够自动生成这样的文件,其中包含一组 Maven 制品的可传递依赖关系。可以运行该工具一次,以生成项目的初始 WORKSPACE 文件;然后,可以手动更新该文件,以调整每个依赖项的版本。
再次强调,这是一种方便性和扩展性之间的选择。小型项目可能本身无需担心管理可传递依赖关系,并且可能无需使用自动可传递依赖关系。随着组织和代码库的增长,冲突和意外结果变得越来越频繁,此策略变得越来越没有吸引力。在较大规模时,手动管理依赖项的成本远低于处理自动管理依赖项引起的问题的成本。
外部依赖项通常由发布稳定版本的库(可能未提供源代码)的第三方提供。一些组织还会选择将自己的一些代码作为制品提供,以便其他代码可以作为第三方(而非内部依赖项)依赖它们。如果制品的构建速度很慢但下载速度很快,理论上,可加快构建速度。
但是,这种方法也带来了很多开销和复杂性:需要负责构建每个制品并将其上传到制品库,并且客户需要确保自身保持最新版本。调试也变得更加困难,因为系统的不同部分是从存储库中的不同点构建的,并且不再有源代码库树的一致视图。
如前所述,如需解决制品构建时间较长的问题,一种更好的方式是使用支持远程缓存的构建系统。此类构建系统会将每个构建生成的制品保存到工程师共享的位置,因此如果开发者依赖其他人最近构建的制品,构建系统会自动下载无需构建。这样做提供了直接依赖于工件做法的所有性能优势,同时仍然确保构建与从同一源构建一样。这是 Google 内部使用的策略,Bazel 支持配置使用远程缓存。
依赖于第三方来源的制品本身存在风险。如果第三方源代码(例如:制品库)发生故障,则会有可用性风险,因为如果无法下载外部依赖项,整个构建可能会停止。还有一种安全风险:如果第三方系统遭到攻击者入侵,攻击者可以将引用的制品替换为他们自己的设计之一,从而将任意代码注入到您的 build 中。将依赖的任何制品镜像到受控的服务器,并阻止构建系统访问 Maven Central 等第三方制品库,可以解决这两个问题。需要权衡的是,镜像需要精力和资源维护,因此,是否使用它们通常取决于项目的规模。通过在源存储库中指定每个第三方制品的哈希值,也可以完全防止安全问题,而开销很小,如果制品被篡改,则会导致构建失败。另一种完全回避问题的替代方法是拷贝(vendor)项目的依赖项。当项目拷贝(vendor)其依赖项时,它会将这些依赖项和项目的源代码(源代码或二进制文件)签入源代码控制系统。这实际上意味着,项目的所有外部依赖项都会转换为内部依赖项。Google 在内部使用此方法,将整个 Google 中引用的每个第三方库签入 Google 源代码树根目录下的 third_party 目录。但是,这仅在 Google 有效,因为 Google 的源代码控制系统是为了处理超大单一代码库而专门构建的,因此拷贝可能不适合所有组织。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/06-08-2022/dependency-management-cn.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!
“幸福的家庭都是相似的,不幸的家庭却各有各的不幸”,托尔斯泰的名言。在 BUG 定位这件事情上,其实也有类似的现象:”菜鸟们的紧张无措都是相似的,老鸟们的方法却各有各的不同”。
年年过大促,年年定位现网故障。果不其然,今年 9.9 大促再次踩坑。看到组内的新手们排查问题的手足无措,也就有了这篇文档。本文的目的不在于穷举所有排查问题的手段,而在于帮助新手们避雷。
当遇到无法轻易复现,并且缺少有用的日志辅助缺陷排查的时候,新手们的一般会选择去看代码。然而,最关键的点来了,他们并不是在真正看代码,他们只是印证自己”脑海中记忆的代码” 跟代码库里的是一致的。最常见的一个结果,就是得到一个 “代码是这样的呀、没有问题呀” 的结论。看代码过程是轻浮的,是跳跃的。
然而,计算机执行的并不是你脑海中的代码,而是实实在在的代码。计算机是严谨的、会一丝不苟的,从调用入口开始,一行不漏的逐行执行完毕,然后返回结果。任何细微的差异都有可能执行的是路径完全不同,而 BUG 就是因为走了跟预期不一样的执行路径。
看代码需要一行一行的看,一层层调用的展开。无论是自己编写的代码,还是开源仓库的代码,还是服务框架的代码,任何一行代码都不应该被跳过。
脚踏实地,而不是蜻蜓点水。
大型复杂的系统产生了繁多的数据。不同的团队成员看到数据(事实)之后,会加入自己对数据的判断(观点),呈现出二次加工之后的数据。最终可能得到是一份夹杂了观点和事实的数据。
当你听到蹄子声响时,你可以说听到了马蹄声,但实际上也可能是斑马蹄的声音,虽然概率很低。
同时,就会出现以下类型的数据误用:
基于错误的、片面的数据,进行假设,最终大概率是徒劳而无功。
靠谱的使用数据的方式,应当是团队成员把相关的数据汇聚,根据业务架构形成 “马赛克调查墙”,基于 “马赛克调查墙” 确定方向,再进行假设。
很多很多人一上来就开始猜答案,基于他们认定的答案来提问,这是特别坏的一个习惯,因为这样找问题几乎就只能凭运气了。
“分治”(Divide & Conquer)是一种非常通用的解决方案。在一个多层系统中,整套系统需要多层组件共同协作完成。最好的办法通常是从系统的一端开始,逐个检查每一个组件,直到系统最底层。这样的策略非常适用于数据处理流水线。在大型系统中,逐个检查可能太慢了,可以采用对分法(bisection)将系统分为两部分,确认问题所在再重复进行。逐项排除、层层递进,才能系统的剥离出真相。
还有一个常见的逻辑误区“相关性 = 因果性”。然而,相关性并不代表因果性。比如:
统计表明,游泳死亡人数越高,冰糕卖得越多,也就是游泳死亡人数和冰糕售出量之间呈正相关性,我们可以由此得出结论说吃冰糕就会增加游泳死亡风险吗?显然不可以!这两个事件显然都仅仅是夏天到了气温升高了所导致的,吃不吃冰糕跟游泳死亡风险根本没有任何因果关系。
同理,跟 BUG 相关的异常数据,不代表数据的操作导致了 BUG。为了论证因果关系,需要更加严密的实证来说明。按照相关理论复现所有 BUG 表现的特性,且只表现这些特征。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/09-13-2021/how-to-locate-bug-in-production-env.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!