☰
C/C++、操作系统、计算机网络:面试核心考点与实战解析
2026/10/12 13:50:55 网站建设 项目流程

1. 为什么这三块总是被放在一起考

1.1 面试官的真实筛选逻辑

C/C++、操作系统、计算机网络这三块被捆绑考察,不是面试官偷懒,而是这三者构成了后端和底层岗位最基础的“能力三角”。我面过不少人,也帮团队筛过简历,说句实在话:一个人如果C/C++语法背得滚瓜烂熟,但说不清一个malloc背后发生了什么,那他的知识是悬空的;如果他能把TCP三次握手倒背如流,却解释不了为什么listen的第二个参数叫backlog,那他的网络知识也是纸糊的。

面试官把这三块放一起,本质是在验证一件事:你对“程序从写下到跑起来”这条完整链路有没有贯通的理解。C/C++是你写程序的工具,操作系统是程序运行的舞台,网络是程序跟外界对话的通道。三者缺一块,链路就断了。

我见过太多候选人,简历上写着“精通C++”,结果问到std::vector扩容时push_back发生了什么,只能说出“会变大”,再往下问“扩容时旧元素怎么处理”“迭代器为什么失效”,就卡住了。这不是他笨,是他从来没把语言特性和内存管理、操作系统行为联系起来想过。

1.2 这三块知识的内在关联

先把这个“能力三角”的关系理清楚,后面复习才不会各背各的。

C/C++层面,核心是内存模型和对象生命周期。你写的每一个变量、每一次new、每一个栈帧,最终都要落到操作系统的虚拟内存上。指针为什么能乱飞?因为虚拟地址到物理地址的映射是操作系统管的,你拿到的是虚拟地址,越界了不一定立刻崩,但迟早出事。

操作系统层面,核心是进程、线程、内存、IO四大件。进程是资源分配的单位,线程是调度的单位,虚拟内存是隔离的手段,IO是跟外界交互的接口。C/C++里的多线程编程,本质就是在操作系统的线程模型上做文章;C/C++里的内存泄漏,本质就是进程的堆区管理出了问题。

网络层面,核心是协议栈和IO模型。你调一次send,数据从用户态缓冲区拷到内核态socket缓冲区,然后经过TCP/IP协议栈层层封装,最后从网卡出去。这个过程涉及用户态/内核态切换、零拷贝、阻塞/非阻塞、多路复用,每一个点都能跟操作系统和C/C++扯上关系。

所以你看,这三块不是三门独立的课,是同一件事的三个视角。复习的时候如果能把它们串起来,面试时就能做到“问A答A还能带出B和C”,这种候选人面试官最喜欢。

1.3 不同岗位的考察侧重

不是所有岗位都平均考这三块,得看你投的是什么方向。

后端开发岗:三块都考,但网络和操作系统权重更高。C/C++可能只考基础语法和内存管理,但TCP/IP、epoll、进程线程调度这些会往深了问。我面后端的时候,如果候选人说不清epoll的LT和ET区别,基本就减分了。

嵌入式/驱动岗:C语言和操作系统是绝对核心,网络考得相对浅。C语言会问到指针、位操作、内存对齐、volatile这些细节,操作系统会问到中断、内存映射、驱动模型。网络可能只问个socket编程基础。

游戏/图形岗:C++是重中之重,操作系统考内存和线程,网络考得看具体项目。C++会问到虚函数、模板、STL源码、内存池这些,操作系统会问到缓存友好性、多线程同步。

基础架构/中间件岗:三块都是深水区。C++要懂现代C++和性能优化,操作系统要懂内核态机制,网络要懂协议栈实现和IO多路复用。这个方向面试难度最高,但也是最能体现“能力三角”价值的地方。

搞清楚自己投的方向,复习才有主次。但不管哪个方向,这三块的基础题都得会,因为面试官往往从基础题切入,再根据你的回答往深了挖。

2. C/C++高频考点拆解与避坑

2.1 内存管理:从malloc到智能指针

C/C++面试第一道坎,几乎必问内存管理。我整理了一个高频问题清单,按出现频率排序:

问题出现频率难度常见追问
malloc/free和new/delete区别极高基础能否混用、底层实现
内存泄漏怎么排查极高中等工具、预防手段
智能指针原理极高中等shared_ptr循环引用
内存对齐高中等结构体大小计算
栈和堆的区别高基础生长方向、大小限制
野指针和悬空指针高基础如何避免
内存池设计中较难实现思路、适用场景

先说malloc/free和new/delete的区别,这题看着简单,但能答全的人不多。标准答案分四点:new是运算符可以重载,malloc是库函数;new会调用构造函数,malloc只分配内存;new返回具体类型指针,malloc返回void*;new失败抛异常,malloc失败返回NULL。

但面试官想听的不止这些。我通常会追问:“new的底层是怎么实现的?”正确答案是:new表达式先调用operator new分配内存,operator new底层通常调用malloc,然后再调用构造函数。所以new和malloc不是对立的,是包含关系。能答出这一层,说明你真的看过底层实现。

再说内存泄漏排查。很多人只知道“用valgrind”,但面试官会问“线上环境怎么排查”。线上不可能随便跑valgrind,这时候得靠:一是代码review时重点看new和delete是否配对;二是用RAII和智能指针从源头避免;三是如果已经泄漏,通过监控进程内存增长趋势定位,再用gdb attach上去看堆栈。我踩过的坑是:有些泄漏不是真的泄漏,是内存池没释放,这种用valgrind查不出来,得看代码逻辑。

智能指针这块,shared_ptr的循环引用是必问的。两个对象互相持有对方的shared_ptr,引用计数永远不归零,内存就泄漏了。解决办法是用weak_ptr打破循环。但面试官可能追问:“weak_ptr怎么知道对象还活着?”答案是weak_ptr不增加引用计数,它通过shared_ptr的控制块来判断对象是否存活。控制块里有两个计数:强引用计数和弱引用计数,强引用为0时对象销毁,弱引用为0时控制块销毁。

注意:shared_ptr的引用计数是原子操作,多线程环境下安全,但性能有开销。如果确定单线程使用,可以考虑unique_ptr或者自定义的非原子智能指针。

内存对齐这题,很多人只会背“按最大成员对齐”,但实际计算经常出错。规则是:每个成员的偏移量必须是该成员大小的整数倍,结构体总大小必须是最大成员大小的整数倍。比如:

struct A { char a; // 偏移0,大小1 int b; // 偏移必须是4的倍数,所以偏移4,大小4 char c; // 偏移8,大小1 }; // 总大小必须是4的倍数,所以是12

我见过有人算成9,就是忘了最后一步补齐。实际开发中,调整成员顺序可以减小结构体大小,比如把char都放一起,能省不少内存。这在网络协议包和嵌入式开发里特别重要。

2.2 指针与引用:那些年我们踩过的坑

指针和引用是C/C++的灵魂,也是面试的重灾区。先把这个表记住:

特性指针引用
是否可为空可以不可以
是否可重新绑定可以不可以
是否占内存占通常不占(编译器实现)
是否可多级可以不可以
是否必须初始化不必须必须

但面试官不会只问这个表,他们会问场景题。比如:“函数传参用指针还是引用?”标准答案是:如果参数可能为空,用指针;如果参数必须存在,用引用。如果需要在函数内改变指针指向,用指针的指针或者指针的引用。

再比如:“返回局部变量的指针或引用会怎样?”返回指针是悬空指针,返回引用是悬空引用,都是未定义行为。但实际表现可能不同:返回指针可能还能读到旧值(栈内存还没被覆盖),返回引用可能直接崩。这种题面试官想看的是你知不知道“未定义行为”这个概念,而不是具体表现。

我踩过的一个坑:在类成员函数里返回成员变量的引用,如果对象被销毁了,这个引用就悬空了。比如:

class Foo { public: int& getValue() { return value_; } private: int value_; }; Foo* f = new Foo(); int& ref = f->getValue(); delete f; // ref现在悬空了,使用它是未定义行为

这种坑在真实项目里很隐蔽,因为对象销毁后内存可能还没被复用,引用还能“正常”读到旧值,直到某次内存分配覆盖了那块区域才崩。排查起来非常痛苦。

还有函数指针和指针函数、数组指针和指针数组这些容易混淆的概念,我建议用“右左法则”来读:从变量名开始,先右后左,遇到括号就调头。比如int (*p)[10],p先跟*结合说明是指针,再跟[10]结合说明指向数组,所以是数组指针。int *p[10],p先跟[10]结合说明是数组,再跟*结合说明元素是指针,所以是指针数组。

2.3 虚函数与多态:面试官最爱挖的深坑

虚函数是C++面试的深水区,几乎每个面试官都会问。基础题是“虚函数怎么实现多态”,答案是虚函数表(vtable)和虚函数指针(vptr)。每个有虚函数的类有一个vtable,存着虚函数地址;每个对象有一个vptr,指向所属类的vtable。调用虚函数时,通过vptr找到vtable,再找到函数地址。

但面试官会追问:“构造函数能不能是虚函数?”不能。因为虚函数调用依赖vptr,而vptr是在构造函数里初始化的,构造函数执行前vptr还没设置好,所以构造函数不能是虚函数。

“析构函数能不能是虚函数?”能,而且基类析构函数必须是虚函数。否则delete基类指针时,如果指向的是派生类对象,只会调用基类析构函数,派生类的资源泄漏。这是经典坑,我见过线上事故就是基类析构没加virtual,导致派生类的socket没关闭。

“虚函数表在内存的哪个位置?”这个问题有争议,标准没规定。常见实现是放在只读数据段,vptr放在对象内存布局的最前面。所以取对象地址,前8字节(64位)就是vptr。这个知识点在逆向和调试时很有用。

“纯虚函数和虚函数的区别?”纯虚函数没有实现,类变成抽象类不能实例化。但纯虚函数可以有实现,只是必须在类外定义,而且派生类仍然必须重写。这个冷知识很多人不知道。

“虚继承的内存布局?”这个更难,虚继承是为了解决菱形继承的数据冗余问题。虚基类的成员在派生类中只存一份,通过虚基类表指针来定位。实际开发中虚继承很少用,但面试官爱问,因为能区分“背题的”和“真懂的”。

实操心得:如果你在面试中被问到虚函数表的具体内存布局,可以画图解释。画图比纯文字描述清晰十倍,面试官会觉得你理解得透彻。我每次面试都会带纸笔,遇到这种题直接画。

2.4 STL容器:从vector扩容到map底层

STL是C++面试的必考项,尤其是vector和map。先看这个对比表:

容器底层结构查找插入删除迭代器失效
vector动态数组O(1)尾部O(1)O(n)扩容时全部失效
list双向链表O(n)O(1)O(1)只失效被删的
map红黑树O(logn)O(logn)O(logn)只失效被删的
unordered_map哈希表平均O(1)平均O(1)平均O(1)只失效被删的

vector扩容是高频题。当size() == capacity()时再push_back,会分配一块更大的内存(通常是原来的2倍或1.5倍),把旧元素拷贝或移动过去,释放旧内存。所以扩容后所有迭代器、指针、引用都失效。

为什么是2倍或1.5倍?2倍是常见实现,1.5倍是某些实现(比如MSVC)的选择。1.5倍的好处是空间利用率更高,因为多次扩容后,之前释放的内存块可以复用。2倍的好处是扩容次数少,均摊时间复杂度更低。面试官问这个,是想看你有没有想过“为什么”。

map底层是红黑树,保证有序,查找O(logn)。unordered_map底层是哈希表,平均O(1),但最坏O(n)。哈希表扩容时,所有元素要重新哈希,迭代器全部失效。实际开发中,如果key是整数且范围不大,可以用数组代替哈希表,性能更好。

vector的reserve和resize区别也是常考题。reserve只改变capacity,不改变size,不构造元素;resize改变size,会构造或销毁元素。我见过有人用reserve后直接下标访问,结果越界了,因为size还是0。

注意:vector的clear()只销毁元素,不释放内存。如果要释放内存,得用swap技巧:vector<int>().swap(v);。这个技巧在内存敏感的场景很有用。

2.5 现代C++:移动语义与右值引用

C++11之后的面试,移动语义和右值引用几乎是必问的。先理清概念:左值是能取地址的表达式,右值是临时的、不能取地址的表达式。右值引用T&&可以绑定右值,用来实现移动语义。

移动语义的核心是“偷资源”。比如vector的移动构造函数,不拷贝元素,直接把源对象的指针拿过来,然后把源对象置空。这样避免了深拷贝,性能提升巨大。

class MyVector { public: MyVector(MyVector&& other) noexcept : data_(other.data_), size_(other.size_) { other.data_ = nullptr; other.size_ = 0; } private: int* data_; size_t size_; };

std::move并不移动任何东西,它只是把左值转换成右值引用,让编译器选择移动构造函数。所以std::move之后的对象不应该再使用,因为它的资源可能已经被偷走了。

完美转发是另一个高频题。std::forward保持参数的值类别,左值转发后还是左值,右值转发后还是右值。实现依赖引用折叠:T&& &&折叠成T&&,T& &&折叠成T&。

template<typename T> void wrapper(T&& arg) { func(std::forward<T>(arg)); }

面试官可能追问:“移动构造函数为什么要加noexcept?”因为vector扩容时,如果移动构造函数可能抛异常,为了保证强异常安全,会退化成拷贝。加上noexcept,vector才会用移动。这个细节很多人不知道,但实际开发中影响很大。

3. 操作系统核心考点与实战理解

3.1 进程与线程:从创建到调度

进程和线程是操作系统面试的基石。先把这个对比表刻在脑子里:

维度进程线程
定义资源分配的基本单位调度的基本单位
地址空间独立共享所属进程的
切换开销大(切换页表、刷新TLB)小(只切换栈和寄存器)
通信方式IPC(管道、消息队列、共享内存)直接读写共享变量
崩溃影响只影响自己影响整个进程
创建开销大(fork复制页表)小(clone共享资源)

Linux下创建进程用fork,创建线程用clone。fork之后子进程是父进程的副本,但用的是写时复制(COW)技术,只有真正写的时候才复制物理页。所以fork本身很快,但fork之后如果子进程立刻exec,COW的页根本不会被复制,效率很高。

线程调度策略有CFS(完全公平调度)、实时调度等。CFS按虚拟运行时间排序,用红黑树管理,每次选虚拟运行时间最小的线程运行。虚拟运行时间跟实际运行时间和权重有关,权重由nice值决定。nice值越低,权重越高,分到的CPU时间越多。

面试官常问:“线程切换为什么比进程切换快?”因为线程共享地址空间,切换时不需要切换页表,也不需要刷新TLB(快表)。但线程切换仍然要保存和恢复寄存器、栈指针等上下文,所以也不是零开销。

“协程和线程的区别?”协程是用户态调度,切换不需要陷入内核,开销更小。但协程不能被操作系统调度,一个协程阻塞了,整个线程都阻塞。所以协程适合IO密集型任务,配合非阻塞IO使用。

实操心得:如果你在项目里用过线程池,面试时一定要主动提。面试官会问“线程池的线程数怎么定”,标准答案是:CPU密集型任务设为CPU核数+1,IO密集型任务设为CPU核数*2或更多。但实际要看具体场景,我一般会压测调优,找到吞吐量最大的那个点。

3.2 内存管理:虚拟内存与页面置换

虚拟内存是操作系统的核心机制,也是面试的深水区。每个进程有独立的虚拟地址空间,通过页表映射到物理内存。页表是多级结构,64位系统通常用4级页表,减少页表本身的内存占用。

地址翻译过程:CPU发出虚拟地址,MMU查TLB,命中则直接得到物理地址;未命中则查页表,找到物理页框号,加上页内偏移得到物理地址,同时更新TLB。如果页表项不在内存(换出了),触发缺页异常,操作系统从磁盘换入页面。

页面置换算法常考FIFO、LRU、Clock。LRU理论最好但实现开销大,实际系统用Clock的变种。Linux用双链表的LRU近似算法,活跃页和不活跃页分开管理。

“缺页中断的处理流程?”这是高频题。步骤是:CPU访问虚拟地址,MMU查页表发现页不在内存,触发缺页异常;操作系统保存现场,找到缺失的页;如果物理内存满了,选一个页换出(如果脏则写回磁盘);从磁盘读入缺失的页;更新页表;恢复现场,重新执行指令。

“内存映射mmap有什么用?”mmap把文件或设备映射到进程地址空间,读写内存就是读写文件,省去了read/write的系统调用和数据拷贝。常用于大文件处理、共享内存、设备驱动。

“malloc分配的内存是虚拟的还是物理的?”是虚拟的。malloc调用brk或mmap向操作系统申请虚拟地址空间,但物理页直到真正访问时才分配(按需分页)。所以malloc很大一块内存可能很快返回,但真正写的时候才占物理内存。

3.3 IO模型:从阻塞到多路复用

IO模型是网络编程的基础,也是面试必问。先看这个对比:

IO模型阻塞阶段特点适用场景
阻塞IO数据准备+数据拷贝都阻塞简单连接少
非阻塞IO数据准备不阻塞,拷贝阻塞轮询耗CPU连接少且不活跃
IO多路复用数据准备阻塞在select/poll/epoll一个线程管多个连接高并发
信号驱动IO数据准备不阻塞,拷贝阻塞信号通知较少用
异步IO都不阻塞内核完成所有操作后通知高性能场景

select、poll、epoll的区别是高频题。select用位图,有FD_SETSIZE限制(通常1024),每次调用要拷贝fd集合到内核,返回后要遍历所有fd找就绪的。poll用数组,没有数量限制,但同样要拷贝和遍历。epoll用红黑树管理fd,就绪的fd放在就绪链表,不需要遍历,也不需要每次拷贝。

epoll的LT和ET模式是必问的。LT(水平触发)只要fd就绪就会一直通知,直到处理完;ET(边缘触发)只在状态变化时通知一次,必须一次处理完所有数据。ET模式效率更高,但编程更复杂,必须配合非阻塞IO,循环读到EAGAIN为止。

// ET模式下的读操作 while (true) { ssize_t n = read(fd, buf, sizeof(buf)); if (n == -1) { if (errno == EAGAIN || errno == EWOULDBLOCK) { break; // 数据读完了 } // 处理错误 } else if (n == 0) { // 对端关闭 break; } // 处理数据 }

注意:ET模式下如果没读完就等下一次事件,剩下的数据可能永远不会被通知,导致连接卡死。这是新手最容易踩的坑。

3.4 死锁:从产生到排查

死锁是操作系统面试的经典题。四个必要条件:互斥、占有并等待、不可抢占、循环等待。破坏任意一个就能避免死锁。

实际开发中,最常见的死锁是锁顺序不一致。线程A先锁M1再锁M2,线程B先锁M2再锁M1,两者各持一个锁等对方释放,死锁。解决办法是规定全局锁顺序,所有线程按同一顺序加锁。

“怎么排查死锁?”如果是gdb调试,可以attach上去看各线程的堆栈,找到卡在锁上的线程。如果是线上环境,可以用pstack或gdb批量打印堆栈,分析锁的持有关系。更高级的用valgrind --tool=helgrind或者ThreadSanitizer检测。

“死锁和活锁的区别?”死锁是互相等待,活锁是互相谦让导致都无法前进。比如两个线程都检测到冲突,都退让重试,结果又冲突,循环往复。解决办法是引入随机退避时间。

“自旋锁和互斥锁的区别?”自旋锁忙等,不睡眠,适合锁持有时间极短的场景;互斥锁睡眠等待,适合锁持有时间长的场景。自旋锁在单核上没意义,因为自旋的线程占着CPU,持有锁的线程没法运行。

4. 计算机网络核心考点与协议细节

4.1 TCP三次握手与四次挥手

TCP是网络面试的重中之重,三次握手和四次挥手是必考题。先看状态转换:

阶段客户端状态服务端状态报文
握手1SYN_SENTLISTENSYN
握手2SYN_SENTSYN_RCVDSYN+ACK
握手3ESTABLISHEDESTABLISHEDACK
挥手1FIN_WAIT_1ESTABLISHEDFIN
挥手2FIN_WAIT_2CLOSE_WAITACK
挥手3TIME_WAITLAST_ACKFIN
挥手4TIME_WAITCLOSEDACK

“为什么是三次握手不是两次?”核心原因是防止已失效的连接请求突然到达服务端,导致服务端建立无效连接。如果只有两次,客户端发了一个SYN,网络延迟很久才到服务端,服务端回复SYN+ACK后直接建立连接,但客户端早就不要这个连接了,服务端的资源就浪费了。三次握手让客户端再确认一次,避免这个问题。

“为什么是四次挥手不是三次?”因为TCP是全双工的,双方都要单独关闭自己的发送通道。服务端收到FIN后,可能还有数据要发,所以先回ACK,等数据发完再发FIN。如果服务端没有数据要发,ACK和FIN可以合并,变成三次挥手。

“TIME_WAIT为什么要等2MSL?”两个原因:一是保证最后一个ACK能到达服务端,如果丢了,服务端会重发FIN,客户端还能响应;二是让本次连接的报文在网络中消失,避免影响新连接。2MSL是报文最大生存时间的两倍,确保来回都能覆盖。

“服务端大量TIME_WAIT怎么处理?”TIME_WAIT是主动关闭方产生的,服务端如果大量TIME_WAIT,说明服务端主动关闭了大量连接。可以设置SO_REUSEADDR允许重用地址,或者调整内核参数tcp_tw_reuse和tcp_tw_recycle(注意后者在NAT环境下有问题,新内核已移除)。

“CLOSE_WAIT过多说明什么?”说明服务端收到FIN后没有及时调用close,通常是代码bug,比如忘了关闭socket,或者业务逻辑卡住了。这个状态堆积会耗尽文件描述符。

4.2 TCP可靠传输与流量控制

TCP的可靠传输靠序列号、确认号、重传机制。序列号保证有序,确认号告诉对方我收到了哪里,超时重传保证丢失的报文能补上。

“超时重传的时间怎么定?”TCP用自适应算法,根据RTT(往返时间)动态调整。公式是:RTO = SRTT + 4 * RTTVAR,其中SRTT是平滑RTT,RTTVAR是RTT偏差。每次测量新的RTT后更新这两个值。这个算法叫Jacobson/Karels算法。

“快速重传是什么?”接收方收到乱序报文时,会重复发送最后一个有序报文的ACK。发送方连续收到3个重复ACK,就知道中间有报文丢了,立刻重传,不用等超时。这比超时重传快得多。

“滑动窗口怎么工作?”接收方通过窗口字段告诉发送方自己还能收多少数据。发送方根据窗口大小调整发送速率,窗口为0时停止发送,但会定期发窗口探测报文,防止死锁。

“拥塞控制有哪些算法?”经典的是慢启动、拥塞避免、快速重传、快速恢复。慢启动时窗口指数增长,到阈值后线性增长(拥塞避免),丢包时窗口减半(快速恢复)或降到1(超时)。现代Linux用CUBIC算法,更适合高带宽长距离网络。

4.3 HTTP与HTTPS:从请求到响应

HTTP是应用层协议,面试常问版本差异和HTTPS握手。

HTTP/1.0和1.1的区别:1.1支持长连接(Keep-Alive)、管道化、分块传输、Host头。1.0每次请求都要新建连接,效率低。

HTTP/2的改进:二进制分帧、多路复用、头部压缩、服务端推送。多路复用解决了HTTP/1.1的队头阻塞问题,一个连接上可以并行多个请求。

HTTP/3基于QUIC,用UDP代替TCP,解决了TCP的队头阻塞,还支持0-RTT连接建立。但QUIC在国内的普及度还不高。

HTTPS握手过程:客户端发ClientHello(支持的加密套件、随机数),服务端回ServerHello(选定的加密套件、随机数、证书),客户端验证证书,生成预主密钥用服务端公钥加密发送,双方用三个随机数生成会话密钥,然后切换加密通信。

“HTTPS为什么安全?”因为非对称加密交换密钥,对称加密传输数据,证书验证身份,MAC保证完整性。三者结合,既安全又高效。

“证书链怎么验证?”客户端从服务端证书开始,用签发者的公钥验证签名,逐级往上,直到根证书。根证书是自签名的,预置在操作系统或浏览器里。如果中间某个证书过期或被吊销,验证失败。

4.4 网络编程实战:从socket到epoll

网络编程的实操题越来越多,面试官会问“怎么实现一个高并发服务器”。标准答案是:epoll + 非阻塞IO + 线程池。

// epoll服务器核心代码 int epfd = epoll_create1(0); struct epoll_event ev, events[MAX_EVENTS]; ev.events = EPOLLIN | EPOLLET; ev.data.fd = listenfd; epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev); while (true) { int n = epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == listenfd) { // 接受新连接 int connfd = accept(listenfd, ...); set_nonblocking(connfd); ev.events = EPOLLIN | EPOLLET; ev.data.fd = connfd; epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev); } else { // 处理数据 handle_request(events[i].data.fd); } } }

“accept之后为什么要设非阻塞?”因为ET模式下,如果accept返回的连接不是非阻塞的,后续read可能阻塞整个epoll循环。而且ET模式下必须一次处理完所有就绪事件,阻塞IO会破坏这个前提。

“epoll的惊群问题怎么解决?”多个线程同时epoll_wait同一个epfd,新连接到来时所有线程都被唤醒,但只有一个能accept成功。解决办法是用EPOLLEXCLUSIVE标志(Linux 4.5+),或者只让一个线程accept,然后把连接分发给其他线程。

“怎么处理大量并发连接?”一是用epoll管理所有连接,二是用非阻塞IO避免阻塞,三是用线程池处理业务逻辑,四是用内存池减少malloc开销,五是用连接池复用连接。我实测下来,单机10万连接用epoll+非阻塞IO是可行的,但要注意文件描述符限制和内存占用。

5. 高频场景题与排查技巧实录

5.1 内存泄漏排查实战

线上服务跑着跑着内存涨了,怎么排查?我的步骤是:

第一步,确认是不是真泄漏。用top或ps看RSS增长趋势,如果一直涨不回落,大概率是泄漏。但要注意,有些内存是缓存,涨到一定程度会稳定,这不是泄漏。

第二步,用工具定位。如果是测试环境,直接上valgrind。如果是线上,用gdb attach上去,call malloc_stats()看堆统计,或者用pmap看内存段分布。

第三步,分析代码。重点看new/delete、malloc/free是否配对,看容器是否只增不减,看循环引用。我遇到过一个案例:日志模块每次写日志都new一个对象,但异常路径下忘了delete,导致泄漏。这种要靠代码review和静态分析工具。

第四步,修复后验证。修复后跑压测,观察内存是否稳定。如果还涨,继续排查。

实操心得:预防内存泄漏最好的办法是用RAII和智能指针,从源头避免手动管理内存。如果必须手动管理,用goto统一清理路径,或者用scope_guard模式。

5.2 CPU飙高排查实战

CPU突然飙到100%,怎么定位?我的步骤是:

第一步,用top找到高CPU的进程,再top -H -p pid找到高CPU的线程。

第二步,把线程ID转成十六进制,用gdb attach上去,thread apply all bt看所有线程堆栈,找到对应线程的堆栈。

第三步,分析堆栈。如果是死循环,堆栈会停在某个循环里;如果是锁竞争,堆栈会停在futex;如果是计算密集,堆栈会在业务函数里。

第四步,修复。死循环就改循环条件,锁竞争就减小锁粒度,计算密集就优化算法或加缓存。

我踩过的坑:有一次CPU飙高,堆栈显示在std::string的拷贝构造函数里。原因是日志打印时传了大字符串,每次拷贝开销巨大。改成传引用后,CPU降了一半。

5.3 网络延迟排查实战

服务响应慢,怎么排查是网络问题还是应用问题?我的步骤是:

第一步,用ping和traceroute看网络连通性和延迟。如果ping延迟高,是网络问题;如果ping正常但服务慢,是应用问题。

第二步,用tcpdump抓包,看TCP握手时间、重传率、窗口大小。如果握手时间长,可能是DNS解析慢或网络拥塞;如果重传率高,可能是丢包;如果窗口小,可能是接收方处理慢。

第三步,用strace看系统调用耗时。如果read/write耗时长,是IO问题;如果futex耗时长,是锁竞争。

第四步,用perf看CPU热点。如果热点在业务函数,优化业务;如果在系统调用,优化IO。

我遇到过一个案例:服务响应时间从10ms涨到100ms,抓包发现TCP握手正常,但第一个数据包发出后等了80ms才收到ACK。查下来是Nagle算法和延迟ACK叠加导致的。设置TCP_NODELAY后,延迟降到10ms。

5.4 常见问题速查表

问题可能原因排查工具解决方向
内存泄漏new/delete不配对、循环引用valgrind、gdbRAII、智能指针
CPU飙高死循环、锁竞争、计算密集top、gdb、perf优化算法、减小锁粒度
网络延迟丢包、拥塞、Nagleping、tcpdumpTCP_NODELAY、调窗口
连接超时backlog满、accept慢netstat、ss调backlog、多线程accept
TIME_WAIT过多主动关闭多netstatSO_REUSEADDR、调内核参数
CLOSE_WAIT过多忘了closenetstat检查代码、加超时
死锁锁顺序不一致gdb、pstack规定锁顺序、用超时锁
段错误空指针、越界、悬空指针gdb、core dump加断言、用智能指针

这张表建议打印出来贴在工位上,遇到问题先查表,能省不少时间。

6. 复习策略与面试技巧

6.1 按岗位调整复习重点

复习不能平均用力,得按岗位调整。我整理了一个权重表:

知识点后端嵌入式游戏基础架构
C++语法中高高高
内存管理高高高高
操作系统高高中高
网络协议高低中高
多线程高中高高
性能优化中中高高
分布式中低低高

后端岗重点在操作系统和网络,嵌入式重点在C和操作系统,游戏重点在C++和性能,基础架构全都要深。先确定方向,再分配时间。

6.2 面试中的表达技巧

面试不只是考知识,还考表达。我的经验是:

第一,先给结论再解释。面试官问“TCP为什么三次握手”,先答“防止失效连接请求”,再展开解释。这样面试官知道你抓住了重点。

第二,主动关联。问C++内存管理时,可以带一句“这跟操作系统的虚拟内存有关”,展示知识体系。但别扯太远,点到为止。

第三,承认不会。遇到不会的,直接说“这块我不太熟,但我的理解是...”,别瞎编。面试官最反感不懂装懂。

第四,画图。复杂问题画图解释,比纯文字清晰十倍。我每次面试都带纸笔,遇到内存布局、协议流程就画。

第五,准备项目。面试官一定会问项目,提前准备好:项目背景、你的角色、技术难点、怎么解决的、有什么收获。用STAR法则组织。

6.3 常见面试流程与应对

典型的技术面试流程是:自我介绍、基础题、项目题、场景题、反问。每个环节都有技巧。

自我介绍控制在2分钟,突出跟岗位相关的经历。基础题快速答,别拖。项目题详细答,展示深度。场景题展示思路,别急着给答案。反问环节问团队技术栈、业务方向、成长空间,别问薪资福利。

我见过有人自我介绍讲了5分钟,把大学社团经历都说了,面试官直接打断。自我介绍要精炼,只讲跟岗位相关的。

注意:面试前一定要看岗位JD,把JD里的关键词圈出来,针对性准备。比如JD里写了“高并发”“epoll”,那网络IO模型就是重点。

6.4 长期学习路线建议

这三块知识不是背出来的,是练出来的。我的建议是:

C/C++:读《Effective C++》《深度探索C++对象模型》,写一个简易STL,理解内存管理和模板。

操作系统:读《深入理解计算机系统》《操作系统导论》,写一个简易内核或驱动,理解进程调度和内存管理。

网络:读《TCP/IP详解》《UNIX网络编程》,写一个简易HTTP服务器,理解协议栈和IO模型。

每块都要动手,光看书没用。我当年学网络,就是自己写了一个epoll服务器,压测到1万并发,才真正理解epoll的优势。

最后分享一个小技巧:把这三块知识画成一张思维导图,中心是“程序从写下到跑起来”,分支是C/C++、操作系统、网络,每个分支再细化。复习时看导图,能快速回忆知识体系。面试前看一遍,心里有底。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询