内存泄漏原因以及排查
原因
-
动态内存分配但未释放:
- 通过
new
或malloc
等函数分配内存后,未使用delete
或free
释放内存。 - 在程序的某个执行路径上,忘记了释放已分配的内存。
- 通过
-
循环引用:
- 特别是在使用智能指针(如
std::shared_ptr
)时,两个或多个对象相互引用,导致引用计数无法归零,从而无法释放内存。
- 特别是在使用智能指针(如
-
未清理的全局或静态对象:
- 程序终止后,全局或静态对象未被正确清理,这些对象可能持有大量资源。
-
内存分配和释放的不匹配:
- 使用了不匹配的分配和释放函数,比如用
malloc
分配内存却用delete
释放,或用new
分配内存却用free
释放。
- 使用了不匹配的分配和释放函数,比如用
-
未处理异常:
- 在发生异常时,提前跳出了内存释放的代码路径,导致已分配的内存未被释放。
-
缓存未清理:
- 程序运行期间不断向缓存中添加数据,但未及时清理无用的数据,导致内存占用不断增加
排查
- juejin.cn/post/704145…
- jemalloc 编译链接安装jemalloc,设置允许prof
- 修改环境变量
lg_prof_interval:26
表明每增长2^26字节(64M)大小进行一次dump - 运行一段时间后停止
- 时间够了以后,停掉程序。经过一段时间运行,目录下就有很多
.heap
文件,运行20分钟, - 使用jeprof工具比较,取最开始和最后面的两个画图比较
GDB
-
启动程序:
gdb <executable>
命令用于启动GDB,并加载要调试的可执行文件。 -
设置断点:使用
break
或b
命令在源代码的特定位置设置断点。例如,break main
在程序的main
函数处设置断点。 -
运行程序:使用
run
或r
命令来运行程序。如果程序需要命令行参数,可以在run
命令后面添加参数。 -
单步执行:使用
next
或n
命令逐行执行程序,不会进入函数内部。使用step
或s
命令逐行执行程序,并进入函数内部。 -
查看变量:使用
print
或p
命令查看变量的值。例如,print x
将打印变量x
的值。 -
查看堆栈:使用
backtrace
或bt
命令查看当前堆栈跟踪信息。 -
查看源代码:使用
list
或l
命令查看源代码。可以在命令后面指定要显示的行数和文件名。 -
查看寄存器:使用
info registers
命令查看CPU寄存器的值。 -
查找内存泄漏:使用
valgrind
工具结合GDB来检测内存泄漏。 -
跟踪执行流:使用
watch
命令设置观察点,以便在特定条件满足时停止程序执行。 -
记录和回放调试会话:使用
record
命令记录程序的执行,然后使用replay
命令回放调试会话。 -
调试多线程程序:GDB支持调试多线程程序,可以使用
thread
命令切换线程上下文,并使用info threads
命令查看当前活动线程的信息。
crash排查
当C++程序在生产环境中崩溃时,排查问题通常涉及收集和分析崩溃信息、重现问题以及调试。以下是一个系统化的方法来排查生产环境中的C++程序崩溃:
1. 收集崩溃信息
首先,需要收集有关崩溃的详细信息。这包括崩溃日志、core dump文件、错误消息和上下文信息。
日志信息
- 日志文件:确保程序有足够的日志记录,尤其是在崩溃前的关键操作和异常处理部分。
- 错误消息:检查日志中的错误消息和异常信息。
Core Dump 文件
-
生成Core Dump:确保系统配置允许生成core dump文件。可以使用以下命令设置core dump生成:
sh 复制代码 ulimit -c unlimited
-
定位Core Dump:生成的core dump文件通常位于当前工作目录,或者根据系统配置存储在指定位置。
2. 分析Core Dump文件
使用调试工具(如GDB)来分析core dump文件,找出崩溃的原因。
使用GDB分析
-
加载Core Dump:
sh 复制代码 gdb <executable> <core-file>
-
获取堆栈跟踪: 在GDB中,使用
bt
(backtrace)命令查看崩溃时的调用堆栈:sh 复制代码 (gdb) bt
这会显示程序崩溃时的调用栈信息,帮助定位崩溃点。
检查局部变量
-
打印变量值:在GDB中,可以使用
print
命令打印局部变量的值,检查是否有异常值。sh 复制代码 (gdb) print <variable_name>
3. 调试和重现问题
在开发环境中尝试重现问题,并使用调试工具进行深入分析。
编译带有调试信息的程序
-
编译选项:确保在开发环境中重新编译程序时,添加调试信息:
sh 复制代码 g++ -g -o myapp myapp.cpp
使用调试器调试
-
调试程序:
sh 复制代码 gdb ./myapp
设置断点和观察点,逐步执行程序,查找潜在问题。
4. 检查代码中的常见问题
检查代码中是否存在以下常见的导致崩溃的问题:
- 空指针解引用:确保所有指针在使用前都进行了有效性检查。
- 数组越界:检查所有数组和容器的边界,确保访问合法。
- 内存泄漏:使用工具(如Valgrind)检查内存泄漏问题。
- 多线程问题:检查多线程代码,确保没有竞争条件和死锁。
5. 使用分析工具
除了GDB,还有其他工具可以帮助分析和排查崩溃问题:
Valgrind
Valgrind可以用于检测内存错误,包括非法访问和内存泄漏:
sh
复制代码
valgrind --leak-check=full ./myapp
AddressSanitizer
AddressSanitizer是一个快速的内存错误检测工具,可以在编译时启用:
sh
复制代码
g++ -fsanitize=address -o myapp myapp.cpp
./myapp
6. 检查系统和环境
检查程序运行的环境和依赖,确保系统资源充足且依赖库版本匹配:
- 系统资源:检查内存、CPU和磁盘使用情况,确保没有资源不足问题。
- 依赖库版本:确保程序使用的所有库和依赖都与开发和测试环境一致。
总结
当生产环境中的C++程序崩溃时,可以按照以下步骤进行排查:
- 收集崩溃信息,包括ore dump文件。
- 使用GDB分析core dump文件,获取堆栈跟踪和变量信息。
- 在开发环境中重现问题,并使用调试器进行深入分析。
- 检查代码中的常见问题,如空指针解引用、数组越界、内存泄漏和多线程问题。
- 使用分析工具(如Valgrind和AddressSanitizer)进行内存和线程问题检测。
- 检查系统和环境,确保资源充足且依日志和c赖库版本匹配。
这种系统化的方法可以帮助你有效地定位和解决生产环境中的崩溃问题。
举例: 查看cpu型号,支持指令集命令: cat /proc/cpuinfo layout asm 查看汇编指令,调查属于的指令集,看是否支持
模板元编程
模板元编程(Template Metaprogramming)是一种在编译时进行计算和代码生成的技术,在C++中得到了广泛应用。模板元编程利用模板的递归和特化特性,使得在编译时完成复杂的计算和代码生成。以下是模板元编程常用的一些地方及其优势,并通过一个示例进行说明。
常用场景
-
编译时常量计算:
- 可以在编译时进行数学计算、类型计算等,以减少运行时的计算负担。例如,计算阶乘、斐波那契数列等。
-
类型特征与类型推断:
- 用于实现类型特征,如
std::is_same
、std::is_integral
等,以及类型推断和转换,如std::enable_if
、std::decay
等。
- 用于实现类型特征,如
-
静态多态与策略模式:
- 通过模板参数实现静态多态,以避免虚函数的运行时开销,常用于策略模式的实现。
-
编译时错误检测与优化:
- 利用模板元编程进行编译时检查,提供更好的编译时错误信息,帮助开发者在编译阶段发现错误,并进行编译时优化。
-
元编程库:
- 构建元编程库,如Boost.MPL(MetaProgramming Library)和Boost.Hana,提供丰富的编译时计算和类型操作功能。
优势
-
性能优化:
- 编译时计算减少了运行时的计算开销,提高了程序的执行效率。
-
类型安全:
- 通过编译时类型检查,能够在编译阶段捕捉更多的错误,提高代码的类型安全性。
-
代码复用:
- 模板提供了强大的代码复用能力,通过泛型编程减少重复代码。
-
灵活性与扩展性:
- 模板元编程提供了高度的灵活性和可扩展性,可以根据需求在编译时生成特定的代码。
示例
以下是一个简单的模板元编程示例,演示如何计算阶乘:
cpp
复制代码
#include <iostream>
// 模板元编程计算阶乘
template<int N>
struct Factorial {
static const int value = N * Factorial<N - 1>::value;
};
// 基例特化,N为0时结果为1
template<>
struct Factorial<0> {
static const int value = 1;
};
int main() {
// 编译时计算5的阶乘
std::cout << "Factorial<5>::value = " << Factorial<5>::value << std::endl;
return 0;
}
在这个示例中,Factorial
模板通过递归定义计算N的阶乘,并在编译时确定结果。Factorial<5>::value
在编译时被计算为120,并在运行时直接使用这个值。
更复杂的示例
以下是一个更复杂的示例,展示如何使用模板元编程实现类型特征和选择:
cpp
复制代码
#include <iostream>
#include <type_traits>
// 定义一个模板类,用于判断类型是否是指针
template<typename T>
struct IsPointer {
static const bool value = false;
};
// 对指针类型进行特化
template<typename T>
struct IsPointer<T*> {
static const bool value = true;
};
// 一个简单的函数,利用IsPointer来选择不同的实现
template<typename T>
void CheckType(T) {
if (IsPointer<T>::value) {
std::cout << "Type is a pointer" << std::endl;
} else {
std::cout << "Type is not a pointer" << std::endl;
}
}
int main() {
int a;
int* b;
CheckType(a); // 输出: Type is not a pointer
CheckType(b); // 输出: Type is a pointer
return 0;
}
在这个示例中,IsPointer
模板用于判断一个类型是否是指针,并通过模板特化处理指针类型。函数CheckType
利用IsPointer
在编译时进行类型选择,并在运行时输出相应的信息。
总结
模板元编程是一种强大的编译时计算和代码生成技术,广泛应用于C++编程中。其常用场景包括编译时常量计算、类型特征与类型推断、静态多态与策略模式、编译时错误检测与优化以及构建元编程库。模板元编程具有性能优化、类型安全、代码复用和灵活性与扩展性等优势,通过上述示例展示了其基本用法和应用场景。
编译器优化
编译器优化 是指编译器在编译代码时,应用各种技术以提高生成的机器代码的性能、减少代码的体积或提升代码的效率。以下是一些常见的编译器优化技术及其具体示例:
- 常量折叠(Constant Folding) 编译器在编译时就计算常量表达式的值,并将其替换为结果值。
示例: int main() { int x = 3 + 4; // 常量折叠,将被替换为 int x = 7; return x; } 2. 常量传播(Constant Propagation) 编译器将程序中已知的常量值传播到它们被使用的地方。
示例: int main() { int a = 10; int b = a + 5; // 常量传播,将被替换为 int b = 15; return b; } 3. 死代码消除(Dead Code Elimination) 编译器移除那些永远不会被执行的代码或不会影响程序输出的代码。
示例: int main() { int a = 5; int b = 10; return a; // b = 10 是死代码,将被消除 } 4. 循环展开(Loop Unrolling) 编译器将循环体展开以减少循环控制的开销。
示例: void loop_unroll_example(int* arr, int size) { for (int i = 0; i < size; i++) { arr[i] = i; } }
// 优化后 void loop_unroll_example(int* arr, int size) { for (int i = 0; i < size; i += 4) { arr[i] = i; arr[i + 1] = i + 1; arr[i + 2] = i + 2; arr[i + 3] = i + 3; } } 5. 函数内联(Function Inlining) 编译器将函数调用替换为函数体本身,从而消除函数调用的开销。
示例: inline int add(int a, int b) { return a + b; }
int main() { int result = add(3, 4); // 函数内联,将被替换为 int result = 3 + 4; return result; } 6. 寄存器分配(Register Allocation) 编译器尽量将变量分配到寄存器中,以减少内存访问的次数。
示例: void register_allocation_example() { int a = 5; int b = 10; int c = a + b; // 编译器会尽量将 a, b, c 分配到寄存器中 } 7. 公共子表达式消除(Common Subexpression Elimination) 编译器检测并消除在一个表达式中重复计算的子表达式。
示例: void common_subexpression_elimination_example() { int a = 5; int b = 10; int c = a * b + a * b; // 公共子表达式 a * b,将被优化为 int tmp = a * b; int c = tmp + tmp; } 8. 代码移动(Code Motion) 编译器将循环内不变的代码移动到循环外,以减少不必要的重复计算。
示例: void code_motion_example(int* arr, int size, int multiplier) { for (int i = 0; i < size; ++i) { arr[i] *= multiplier; // multiplier 不变,可以移动到循环外 } }
// 优化后 void code_motion_example(int* arr, int size, int multiplier) { int m = multiplier; for (int i = 0; i < size; ++i) { arr[i] *= m; } } 9. 跳跃优化(Jump Optimization) 编译器通过优化条件语句和跳跃指令来减少不必要的跳跃,从而提高执行效率。
示例: void jump_optimization_example(int a, int b) { if (a < b) { // do something } else { // do something else } }
// 优化后 void jump_optimization_example(int a, int b) { if (!(a >= b)) { // do something } else { // do something else } } 10. 预取(Prefetching) 编译器在预计将来需要访问的内存位置时,提前加载数据以减少缓存未命中(cache miss)。
示例: void prefetch_example(int* arr, int size) { for (int i = 0; i < size; ++i) { __builtin_prefetch(&arr[i + 1], 0, 1); // 预取下一个数据 arr[i] = arr[i] * 2; } } 总结 编译器优化通过分析和变换代码,提高了程序的性能和效率。编译器在保证程序语义不变的前提下,应用各种优化技术,使生成的机器代码运行得更快、占用更少的资源。在实践中,编译器通常会结合多种优化技术以实现最佳性能。
无锁编程
无锁编程(lock-free programming)是一种并发编程技术,旨在在多线程环境中避免使用锁(如互斥锁)来同步对共享资源的访问。无锁编程通过使用原子操作来保证并发访问的正确性和一致性,从而提高程序的性能和可扩展性。
在C++中,无锁编程主要依赖于C++11及以后的标准库中的原子操作和内存模型。这些功能由<atomic>
头文件提供。
主要概念
- 原子操作:原子操作是不可分割的操作,即使在多线程环境中,也不会被其他线程的操作打断。C++标准库提供了对基本数据类型的原子操作支持,如
std::atomic<int>
、std::atomic<bool>
等。 - 内存序:内存序定义了不同线程间内存访问的可见性和顺序。常见的内存序有
memory_order_relaxed
、memory_order_acquire
、memory_order_release
、memory_order_acq_rel
和memory_order_seq_cst
。 - CAS操作:Compare-and-Swap(CAS)是一种常用的原子操作,用于在无锁编程中实现条件更新。C++标准库提供了
compare_exchange_strong
和compare_exchange_weak
方法来执行CAS操作。
示例代码
以下是一个简单的无锁栈的实现示例:
cpp
复制代码
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
template<typename T>
class LockFreeStack {
private:
struct Node {
T data;
Node* next;
Node(const T& data) : data(data), next(nullptr) {}
};
std::atomic<Node*> head;
public:
LockFreeStack() : head(nullptr) {}
void push(const T& data) {
Node* newNode = new Node(data);
newNode->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(newNode->next, newNode, std::memory_order_release, std::memory_order_relaxed)) {
// 如果CAS操作失败,则更新newNode->next为最新的head
}
}
bool pop(T& result) {
Node* oldHead = head.load(std::memory_order_acquire);
while (oldHead && !head.compare_exchange_weak(oldHead, oldHead->next, std::memory_order_release, std::memory_order_relaxed)) {
// 如果CAS操作失败,则更新oldHead为最新的head
}
if (oldHead) {
result = oldHead->data;
delete oldHead;
return true;
} else {
return false; // 栈为空
}
}
};
void producer(LockFreeStack<int>& stack, int start, int count) {
for (int i = 0; i < count; ++i) {
stack.push(start + i);
}
}
void consumer(LockFreeStack<int>& stack, int count) {
int value;
for (int i = 0; i < count; ++i) {
if (stack.pop(value)) {
std::cout << value << std::endl;
}
}
}
int main() {
LockFreeStack<int> stack;
std::thread t1(producer, std::ref(stack), 0, 10);
std::thread t2(consumer, std::ref(stack), 5);
std::thread t3(consumer, std::ref(stack), 5);
t1.join();
t2.join();
t3.join();
return 0;
}
注意事项
- ABA问题:在无锁编程中,ABA问题是一个常见的问题。它指的是一个位置上的值从A变成B,又变回A,这可能导致CAS操作误认为值没有改变。解决ABA问题的一个方法是使用带有版本号的指针(如
std::atomic<std::pair<Node*, unsigned>>
)。 - 内存管理:无锁数据结构的内存管理是一个挑战。通常需要使用垃圾回收、引用计数或其他技术来防止内存泄漏或悬空指针。
- 复杂性:无锁编程比使用锁的编程更加复杂,需要仔细设计和调试以确保正确性。通常只有在性能瓶颈或特定需求下才考虑使用无锁编程。
总结
无锁编程可以显著提高多线程程序的性能和可扩展性,但也带来了更多的复杂性和挑战。在C++中,通过使用std::atomic
和合适的内存序,可以实现高效的无锁数据结构和算法。
RPC框架
RPC框架作用
RPC:远程过程调用, 与之对应的是本地调用 代码里屏蔽网络传输
RPC解决问题:
1.怎么知道调用哪个方法(函数映射)
2.屏蔽网络传输(机遇TCP)
函数映射
定义IDL文件,编译工具生成stub桩文件,相当于生成了静态库,实现函数映射,client引入后就知道每个接口对应的id是多,知道怎么调
网络传输
TCP以下都是2进制文件,将请求体序列化成二进制
RPC协议中要保证请求ID和反回结果一一对应
GRPC基于HTTP2传输
对比RPC和HTTP1
1. HTTP是应用层协议,RPC是一种调用方式
2. 所谓RPC协议,实际上是基于TCP,UDP甚至HTTP2改造后的【自定义协议】,并不是业界通用协议
3. 序列化区别
HTTP1: json,空间大,无类型
GRPC: protobuf ,体积小,效率高
4.协议约定
HTTP1:灵活,可以自定义字段
缺点:包含一些为了适应浏览器的冗余字段
RPC:无冗余字段
5. 传输:都是基于socket
HTTP:建立一个TCP长连接,设置keep-alive长时间复用该连接
RPC: 建立TCP连接池
gRPC基于HTTP2,拥有多路复用,优先级控制,头部压缩等优势(HTTP2的优点)
RPC优势:
1.数据包小,序列化快,传输效率高
2.适用于微服务结构
不足:
1.RPC协议本身无法解决集群问题,例如服务发现,服务治理等,需要工具保障稳定性
2.调用方对服务端强依赖,有版本问题
使用场景:
微服务架构下,多个内部服务频繁互相调用,用RPC
对外服务,单体服务,为前端服务,适合HTTP,特别是2,性能很好
服务治理:
服务层一般需要提供:
1. 服务注册
2. 健康检测。第一时间让调用方知道服务出现问题
3. 限流:过载保护,访问量过大,抛出限流异常
客户端一般提供:
1. 服务发现:根据服务名称≈找到ip+端口
2. 路由策略:实现流量隔离,应用于灰度发布
3. 负载均衡:把请求分发到服务集群的各个服务节点
4. 重试机制:
5. 故障熔断:确定下游异常,请求直接截断
服务注册与服务发现
解决问题:服务名->服务地址 (类似DNS 域名->IP)
如何实现?
server:
服务启动后,向注册中心注册自身信息
服务与注册中心保持心跳,注册中心要感知server是否可用
server通知注册中心当前节点下线,注册中心通知client
client:
client第一次发送RPC请求前,向注册中心请求服务节点列表,并缓存在本地
client与注册中心保持数据同步,服务节点有变化时,注册中心通知client,client更新本地缓存
client收到下线通知后,更新本地缓存,选择其他server发送请求
CAP理论
优先保证P(部分节点出现网络故障时,可提供服务)
CP:牺牲一定可用性,保证强一致性,代表有zookeeper。适合集群规模小。 大批量同时上下线时,注册中心可能顶不住,下发大量数据,服务可能长时间不可用
AP:牺牲一定一致性,保证高可用性。Nacos
负载均衡
解决的问题:
算法:随机
轮训:按照固定顺序,挨个访问
加权轮寻:根据成功率调整权重,权重越大,被访问顺序越高
一致性哈希,算哈希值,打到顺时针第一个机器上,同一个来源的请求都映射到同一节点,提高缓存命中率
最小连接法、或者选最快的,用这类指标决定
保证服务稳定性的方法——熔断,限流,降级
熔断
响应时间,错误率,连续错误数等 设置一个指标,持续超过阈值触发熔断
目的——服务端需要时间恢复,歇一歇。2.避免全链路崩溃
流程
Server被监控到异常,触发了熔断
client收到异常,利用负载均衡重新选择节点,后续请求不再打到被熔断的节点
一段时间后,client再对这个节点重新请求
限流
限流指的是对系统的请求频率进行限制,以防止系统因超载而崩溃。通过限流,可以保护系统免受流量突发或恶意攻击的影响,确保系统在高并发场景下仍然能够稳定运行。
常见的限流策略
固定窗口计数法(Fixed Window Counter):在固定的时间窗口内,对请求进行计数,如果超过预设的阈值,就拒绝后续的请求。
滑动窗口计数法(Sliding Window Counter):将时间窗口进一步细分,以提高计数的准确性,避免固定窗口法的边界问题。
令牌桶算法(Token Bucket):系统按照一定的速率向桶中添加令牌,每次请求需要消耗一个令牌,如果桶中没有令牌,则拒绝请求。
漏桶算法(Leaky Bucket):以固定的速率处理请求,超出速率的请求被缓存或丢弃。
降级
降级指的是在系统压力过大或部分功能不可用时,通过有计划地降低系统功能,以保证核心功能的正常运行。降级措施的目标是使系统在非理想情况下仍然能够提供基本服务,而不是完全不可用。
关闭非核心功能:例如,在电商网站中,当系统负载过高时,可以关闭推荐系统或评论系统,保留基本的购物功能。
返回缓存数据:在实时数据获取失败时,返回缓存的旧数据,以保证用户体验的连续性。
延迟处理:将一些非关键的任务延迟执行,例如,将某些后台统计任务延迟到系统空闲时处理。
简化服务:例如,当负载过高时,只提供文本版本的页面,而关闭图像和视频等大流量资源的加载。
epoll 与 sellect
- 网卡与中断 网卡:把接收到的数据写入内存
当网卡把数据写入到内存后,网卡向cpu发出一个中断信号, 操作系统便能得知有新数据到来,再通过网卡中断程序去处理数据。 中断程序主要有两项功能,先将网络数据写入到对应socket的接收缓冲区里面,再唤醒进程,重新将进程A放入工作队列
recv是个阻塞方法,当程序运行到recv时,它会一直等待,直到接收到数据才往下执行。
-
阻塞的原理 socket包含了发送缓冲区,接收缓冲区,等待队列。等待队列指向所有需要等待该socket事件的进程
-
唤醒进程:当socket接收到数据后,操作系统将该socket等待队列上的进程重新放回到工作队列,该进程变成运行状态, 继续执行代码。也由于socket的接收缓冲区已经有了数据,recv可以返回接收到的数据。
-
select的用法:
- 准备一个数组fds,让fds存放着所有需要监视的socket。然后调用select,
- 如果fds中的所有socket都没有数据,select会阻塞,直到有一个socket接收到数据,
- select返回,唤醒进程。用户可以遍历fds,通过FD_ISSET判断具体哪个socket收到数据,然后做出处理。
原理:调用select之后,操作系统把进程A分别加入这三个socket的等待队列中
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int fds[] = 存放需要监听的socket
while(1){
int n = select(..., fds, ...)
for(int i=0; i < fds.count; i++){
if(FD_ISSET(fds[i], ...)){
//fds[i]的数据处理
}
}
}
select缺点:
1.调用时,将进程加入所有socket等待队列,反回时,将进程从所有fd等待队列中移除,遍历了两次
2.唤醒后,不知道哪些socket收到数据,还要遍历一次
epoll:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...)
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1){
int n = epoll_wait(...)
for(接收到数据的socket){
//处理
}
}
epoll 优点:
- 将添加等待队列和阻塞分离,不必每次重新添加等待队列
- 核维护一个“就绪列表”,引用收到数据的socket,就能避免遍历。计算机共有三个socket, 收到数据的sock2和sock3被rdlist(就绪列表)所引用。当进程被唤醒后,只要获取rdlist的内容,就能够知道哪些socket收到数据。
- 调用epoll wait的进程只用加入event poll的等待队列
就绪列表:双向链表,存socket的引用 (快速插入和删除) 监视队列:红黑树,坚固插入、搜索,和删除,都是logn
epoll 原理
当socket收到数据后,中断程序会给eventpoll的“就绪列表”添加socket引用 当程序执行到epoll_wait时,如果rdlist已经引用了socket,那么epoll_wait直接返回,如果rdlist为空,阻塞进程。
LT ET
水平触发是只要读缓冲区有数据,就会一直触发可读信号 而边缘触发仅仅在空变为非空的时候通知一次,
socket 编程
socket socket编程是一种在计算机网络中实现进程间通信的方法,它基于网络套接字(socket),可以在不同的计算机上的进程之间进行通信。Socket编程通常涉及客户端和服务器两个角色,其中客户端向服务器发出请求,而服务器接受请求并提供服务。
下面是Socket编程的一般步骤:
创建Socket:首先,需要创建一个Socket对象,它负责通信的建立和管理。
绑定Socket:如果是服务器端,需要将Socket绑定到一个特定的地址和端口上,以便客户端能够连接。
监听连接(仅服务器端需要):服务器端需要调用listen()函数开始监听来自客户端的连接请求。
接受连接(仅服务器端需要):当有客户端连接请求到达时,服务器端调用accept()函数接受连接,并创建一个新的Socket对象来处理与该客户端的通信。
建立连接(仅客户端需要):客户端向指定的服务器地址和端口发起连接请求。
发送和接收数据:连接建立后,客户端和服务器端可以通过发送和接收数据来进行通信。通常使用send()和recv()函数来发送和接收数据。
关闭连接:通信完成后,需要关闭Socket连接。
Socket编程可以用于实现各种类型的网络应用,包括Web服务器、聊天应用、文件传输等。它提供了一种灵活和强大的方式,使得程序能够在网络中进行通信和交互。
计算机网络八股文
1. 用户输入URL并按下回车键发生了什么
应用层
- DNS解析:首先,浏览器需要将输入的URL转换为IP地址。这涉及到DNS(Domain Name System)协议。 步骤:
- 浏览器检查本地缓存是否有该域名的IP地址。
- 如果没有,向本地DNS服务器发送查询请求(使用UDP或TCP协议,端口53)。
- 本地DNS服务器查询递归或迭代查询,最终获取目标域名的IP地址,并返回给浏览器。
- HTTP/HTTPS请求:DNS解析获得IP地址后,浏览器使用HTTP(超文本传输协议)或HTTPS(HTTP的安全版本,使用TLS/SSL加密)向服务器发起请求。 步骤: 浏览器建立TCP连接(对于HTTPS,还需进行TLS握手)。 浏览器发送HTTP GET请求到服务器,请求资源(如HTML页面、CSS、JavaScript文件等)。
传输层
TCP协议:保证数据包的可靠传输,进行三次握手建立连接。 步骤: 客户端发送SYN包给服务器。 服务器回复SYN-ACK包。 客户端发送ACK包,连接建立。
网络层
IP协议:负责将数据包从源地址传输到目标地址。 步骤: 将HTTP请求数据封装成IP数据包。 通过路由器和其他网络设备,将数据包传输到目标服务器的IP地址。
数据链路层
以太网/Wi-Fi:负责在局域网内传输数据帧。步骤:
- 将IP数据包封装成数据帧。
- 在局域网内传输数据帧,通过交换机、路由器等设备。
物理层
物理介质:通过有线或无线的物理介质传输比特流。 步骤: 在物理介质上传输二进制数据(电信号、光信号、无线电波等)。
2. 服务器处理请求并返回响应
应用层
服务器处理请求:服务器接收到HTTP请求后,处理请求并生成响应。
步骤:
- 服务器接收并解析HTTP请求。
- 服务器根据请求资源路径,读取对应的资源文件(如HTML、CSS、JavaScript等)。
- 服务器生成HTTP响应,包含状态码、头部信息和资源数据。
- HTTP/HTTPS响应:服务器将HTTP响应通过TCP连接发送回客户端。
步骤:
- 服务器发送HTTP响应头和响应体。
- 如果是HTTPS,响应数据在传输前进行加密。
- 传输层、网络层、数据链路层、物理层
- 数据返回客户端:传输层、网络层、数据链路层和物理层负责将服务器返回的数据包传输回客户端(过程与请求类似,方向相反)。
3. 浏览器解析并渲染页面
应用层
- 解析HTML:浏览器接收到HTML文档后,开始解析HTML,构建DOM树。
- 解析CSS:浏览器请求并解析CSS文件,生成CSSOM树。
- 执行JavaScript:解析和执行JavaScript代码,可能会进一步修改DOM和CSSOM。
- 构建渲染树:结合DOM树和CSSOM树,生成渲染树。
- 布局和绘制:计算每个节点的布局(位置和尺寸),然后将渲染树的内容绘制到屏幕上。
涉及的主要协议
- DNS:域名解析协议,用于将域名解析为IP地址。
- HTTP/HTTPS:超文本传输协议/安全超文本传输协议,用于客户端与服务器之间的数据交换。
- TCP:传输控制协议,提供可靠的连接和数据传输。
- IP:互联网协议,负责将数据包从源地址传输到目标地址。
- Ethernet/Wi-Fi:数据链路层协议,用于局域网内的数据传输。
cpu缓存
CPU缓存是计算机处理器中的一种高速存储器,用于暂存频繁访问的数据,以加速数据访问速度。下面是对CPU缓存的详细介绍:
1. CPU缓存的层级结构
CPU缓存通常分为多个层级,每一级缓存的速度和容量不同,具体如下:
-
L1缓存(一级缓存):
- 速度最快,容量最小,通常为几十KB。
- 分为L1指令缓存和L1数据缓存,分别存储指令和数据。
- 每个CPU核心都有自己的L1缓存。
-
L2缓存(二级缓存):
- 速度较快,容量中等,通常为几百KB到几MB。
- 用于存储L1缓存未命中的数据。
- 通常每个核心都有自己的L2缓存,但也有一些设计是共享的。
-
L3缓存(三级缓存):
- 速度较慢,容量较大,通常为几MB到几十MB。
- 主要用于存储L2缓存未命中的数据。
- 通常是所有核心共享的缓存。
2. CPU缓存的作用
CPU缓存的主要作用是减少CPU从主内存(RAM)读取数据的延迟。CPU访问缓存的数据速度要比访问主内存快很多,缓存的引入显著提高了系统的性能。缓存的引入是因为CPU速度发展远快于内存速度,缓存能够在CPU和内存之间提供一个高速缓冲区,从而缓解这一速度差异。
3. CPU缓存的工作原理
CPU缓存的工作原理主要涉及以下几个方面:
-
缓存命中(Cache Hit)和缓存未命中(Cache Miss):
- 当CPU需要访问某个数据时,首先会查找L1缓存。如果找到(缓存命中),则直接读取数据;如果找不到(缓存未命中),则查找L2缓存,依此类推,直到L3缓存。如果所有缓存都未命中,才会从主内存读取数据。
-
缓存行(Cache Line):
- 缓存以缓存行为单位存储数据。缓存行是缓存中可独立管理的最小存储单元,通常为64字节。CPU每次从内存加载数据时,会加载整个缓存行而不是单个字节或字。
-
缓存一致性(Cache Coherence):
- 多核处理器系统中,每个核心有自己的缓存,这就会引入缓存一致性问题。缓存一致性协议(如MESI协议)用于确保多个缓存之间的数据一致性。
4. 缓存的替换策略
当缓存满了且需要新的数据时,缓存替换策略决定了哪些数据应该被替换。常见的替换策略有:
-
LRU(Least Recently Used):
- 替换最近最少使用的数据。
-
FIFO(First In First Out):
- 替换最早进入缓存的数据。
-
随机替换(Random Replacement):
- 随机选择一个缓存行进行替换。
5. 缓存的写策略
缓存的写策略决定了数据写入缓存和内存的时机,常见的写策略有:
-
写直达(Write Through):
- 每次写操作都同时写入缓存和内存。
-
写回(Write Back):
- 写操作只写入缓存,只有当缓存行被替换时才写入内存。
CPU缓存的设计和优化是计算机体系结构中的一个重要领域,通过有效利用缓存可以显著提高系统性能。理解缓存的工作原理有助于更好地优化软件性能,特别是在高性能计算和系统编程中。
虚拟内存
1. 虚拟内存
虚拟内存是一种内存管理技术,它为每个进程提供一个独立的地址空间,使得每个进程认为自己独占整个内存。虚拟内存通过硬件和操作系统的协作,将进程的虚拟地址空间映射到实际的物理内存地址或磁盘上的存储空间。
主要优点:
- 内存保护: 每个进程有独立的地址空间,防止它们相互干扰。
- 内存扩展: 允许系统使用比实际物理内存更多的内存,通过使用磁盘上的交换空间。
- 内存管理简化: 简化了内存分配和碎片管理。
2. 页表
页表是一个数据结构,用于将虚拟地址转换为物理地址。每个进程都有自己的页表。页表将虚拟地址分成若干页(Page),并将这些页映射到物理内存中的页框(Frame)。
页表的组成:
- 页表项(Page Table Entry, PTE): 每个PTE包含一个虚拟页对应的物理页框的地址,以及一些控制位(如存在位、读写权限等)。
3. 页目录
在多级分页机制中,页目录用于管理页表,进一步分级来减少页表大小和管理开销。常见的多级分页包括两级分页和四级分页。
页目录的结构:
- 页目录表(Page Directory Table, PDT): 包含多个页目录项(Page Directory Entry, PDE)。
- 页目录项(PDE): 每个PDE指向一个页表,或者在更高级的分页系统中,指向下一级页目录。
4. 访问内存的过程
当CPU需要访问某个虚拟地址时,具体过程如下:
1. 地址分解:
-
虚拟地址结构: 虚拟地址通常被分为多个字段,每个字段指向不同级别的页表。以32位地址和两级分页为例:
- 目录索引(Directory Index): 高位部分,用于索引页目录项。
- 页表索引(Table Index): 中间部分,用于索引页表项。
- 页内偏移(Offset): 低位部分,用于确定页内的具体地址。
2. 地址转换过程:
- CPU生成虚拟地址: 包含目录索引、页表索引和页内偏移。
- 查找页目录: 使用目录索引在页目录表中找到相应的PDE。
- 查找页表: PDE指向页表,使用页表索引在页表中找到相应的PTE。
- 获取物理地址: PTE提供了物理页框的地址,加上页内偏移形成完整的物理地址。
- 访问物理内存: 使用物理地址访问具体的内存单元。
3. 缓存的作用:
在整个过程中,CPU缓存(如TLB,Translation Lookaside Buffer)用于加速地址转换。TLB缓存了最近使用的虚拟地址到物理地址的映射,减少了查找页表的次数。如果TLB命中,地址转换速度会大大提高;如果TLB未命中,则需要查找页目录和页表。
总结
通过虚拟内存、页表和页目录的协同工作,操作系统能够有效管理内存,为进程提供隔离的、扩展的、灵活的内存空间。而CPU缓存的引入则显著提高了内存访问的效率,使得系统在处理大规模数据时仍能保持高性能。
BRPC
bthread概述
bthread是百度开源的高性能多线程库,设计初衷是为了在高并发场景下高效地管理线程和任务。它的主要特点是轻量级、低开销、支持协程等。bthread在性能上可以媲美传统线程库,并且在一些场景下表现更优。
bthread的原理
-
轻量级线程(协程) :
- bthread在操作系统线程(OS线程)之上实现了用户态的轻量级线程(也称为协程)。这些轻量级线程有更小的上下文切换开销。
- 通过协程的方式,可以在一个OS线程内运行多个bthread,从而提高CPU利用率和程序并发度。
-
工作窃取调度:
- bthread采用了工作窃取调度算法(Work Stealing Scheduler),这种调度算法能够有效地在多个处理器上均衡负载。
- 每个工作线程维护一个双端队列(Deque),优先从自己的队列中获取任务执行。如果自己的队列空了,就从其他线程的队列尾部“窃取”任务执行。
-
任务管理:
- bthread库管理任务的方式灵活,可以支持同步和异步任务执行。
- 通过将任务分割成多个小的、可以并行执行的单元,提高系统的吞吐量。
-
锁和同步机制:
- 提供了一些优化的锁和同步机制,降低线程间竞争带来的性能损耗。
bthread的实现
-
线程池:
- bthread实现了一个高效的线程池,管理一组OS线程,这些线程在系统启动时创建,并且在整个应用程序生命周期内保持运行。
- 线程池中的每个线程负责运行多个bthread,从而实现高效的资源利用。
-
协程调度器:
- 每个OS线程对应一个协程调度器,负责管理和调度该线程上的所有bthread。
- 调度器采用工作窃取算法,尽量保证每个线程都有任务可执行,从而提高整体吞吐量。
-
上下文切换:
- bthread通过用户态的上下文切换实现轻量级线程切换。相比于OS级别的线程切换,用户态切换的开销要小得多。
- 上下文切换主要依赖于保存和恢复寄存器状态,并且通过栈切换来实现。
-
异步任务处理:
- bthread支持将阻塞的I/O操作转化为异步任务,进一步减少了线程阻塞带来的性能瓶颈。
- 通过事件循环和回调机制,实现高效的异步I/O操作。
示例回答
“bthread是百度开源的高性能多线程库,主要特点是轻量级和低开销。其原理是通过在OS线程之上实现轻量级线程(协程),利用工作窃取调度算法来均衡负载。bthread的实现包括一个高效的线程池,每个线程池中的线程管理多个bthread,通过协程调度器进行任务调度。上下文切换在用户态进行,开销较小。同时,bthread还支持将阻塞I/O操作转化为异步任务处理,进一步提高系统性能。”
通过这样的回答,展示你对bthread的原理和实现有深入的理解,能给面试官留下深刻印象。
线程切换
用户态上下文切换和操作系统级别的线程切换在性能和开销方面有显著差异。以下是它们的主要区别、开销所在,以及为什么开销差距如此大的原因:
用户态上下文切换
定义: 用户态上下文切换是在用户空间中进行的轻量级线程(如协程)之间的切换。它通常由用户态库(如bthread、libco等)管理,而不涉及内核操作。
开销:
- 寄存器保存与恢复:只需保存和恢复少量的CPU寄存器状态(如程序计数器、栈指针)。
- 栈切换:切换到新的协程栈,这通常是一个简单的指针调整。
- 无需内核态切换:上下文切换完全在用户态完成,不涉及系统调用或特权模式切换。
性能: 用户态上下文切换的开销非常低,通常在几十到几百纳秒之间,因为它避免了昂贵的内核切换。
操作系统级别的线程切换
定义: 操作系统级别的线程切换是在内核空间中进行的线程或进程之间的切换,由操作系统内核管理。
开销:
- 寄存器保存与恢复:需要保存和恢复所有的CPU寄存器状态,包括通用寄存器、浮点寄存器、控制寄存器等。
- 内核态切换:涉及从用户态到内核态的模式切换,这需要进行特权级别的转换。
- TLB刷新:线程切换可能导致页表切换,需要刷新TLB(Translation Lookaside Buffer),这会带来额外开销。
- 上下文信息管理:需要保存和恢复更多的上下文信息,包括线程的栈、程序计数器、内存管理信息等。
- 调度器开销:操作系统调度器需要选择下一个运行的线程,这涉及复杂的调度算法和优先级管理。
性能: 操作系统级别的线程切换开销较高,通常在几微秒到几十微秒之间。这是由于涉及内核和用户空间的切换、寄存器保存与恢复、TLB刷新等多个复杂步骤。
开销差距的原因
-
模式切换:
- 用户态切换不涉及从用户态到内核态的模式转换,而OS级别线程切换需要进行模式转换,这本身是一个昂贵的操作。
-
上下文保存与恢复:
- 用户态切换仅保存和恢复必要的寄存器状态,而OS级别线程切换需要完整地保存和恢复所有相关的寄存器和上下文信息。
-
调度开销:
- 用户态切换由用户态库直接管理,调度开销很小。OS级别线程切换依赖操作系统调度器,需要复杂的调度算法和优先级管理。
-
内存管理:
- 用户态切换通常不涉及页表和内存管理信息的变化,而OS级别线程切换可能涉及页表切换,需要刷新TLB,带来额外开销。
示例回答
“用户态上下文切换与操作系统级别的线程切换的主要区别在于前者完全在用户空间进行,不涉及内核操作,因此开销非常低。用户态切换只需保存和恢复少量寄存器,并进行栈指针的简单调整。而OS级别的线程切换涉及从用户态到内核态的模式切换、完整的寄存器状态保存与恢复、TLB刷新和复杂的调度器操作。因此,用户态切换的开销通常在几十到几百纳秒,而OS级别切换的开销则在几微秒到几十微秒之间,开销差距显著。”
通过这样的回答,面试官可以清晰地看到你对用户态和操作系统级别上下文切换的理解及其开销差异的深刻认识。
fork
在Unix和类Unix系统中,fork()
系统调用用于创建一个新的进程,称为子进程。子进程是父进程的一个副本,但有一些差异。以下是fork()
时复制和未复制的内容:
复制的内容
-
进程ID和进程组ID:
- 子进程获得一个新的唯一的进程ID(PID),但是继承了父进程的进程组ID。
-
用户ID和组ID:
- 子进程继承了父进程的实际用户ID、有效用户ID、保存的用户ID,以及相应的组ID。
-
文件描述符:
- 子进程继承了父进程的打开文件描述符,每个文件描述符的引用计数增加。文件描述符的文件偏移量在父子进程中是共享的(即如果一个进程改变了偏移量,另一个进程会看到变化)。
-
控制终端:
- 子进程继承了父进程的控制终端(如果有的话)。
-
当前工作目录和根目录:
- 子进程继承了父进程的当前工作目录和根目录。
-
文件模式创建掩码(umask) :
- 子进程继承了父进程的文件模式创建掩码。
-
信号处理设置:
- 子进程继承了父进程的信号处理设置,包括信号屏蔽字和信号处理程序。
-
环境变量:
- 子进程继承了父进程的环境变量。
-
资源限制:
- 子进程继承了父进程的资源限制(如内存限制、CPU时间限制等)。
-
内存映射:
- 子进程获得父进程地址空间的一个副本,包括代码段、数据段、堆和栈。对于写时复制(copy-on-write)的系统,实际内存页只有在写入时才会被复制。
-
定时器:
- 子进程继承父进程的实时定时器和interval timers。
未复制的内容
-
进程ID:
- 子进程有一个唯一的新进程ID(PID)。
-
父进程ID:
- 子进程的父进程ID(PPID)是调用
fork()
的进程ID。
- 子进程的父进程ID(PPID)是调用
-
文件锁:
- 子进程不继承父进程的文件锁。
-
挂起的信号:
- 子进程不继承父进程的挂起信号。
-
进程统计信息:
- 子进程的统计信息,如CPU时间消耗、上下文切换次数等,不从父进程继承。
-
线程:
- 在多线程环境下,子进程只继承调用
fork()
的线程,不会继承其他线程。
- 在多线程环境下,子进程只继承调用
-
互斥锁(Mutexes) :
- 子进程不会继承父进程的互斥锁状态,这意味着锁不会保持锁定状态。
-
内存共享:
- 如果父进程和其他进程共享内存(通过
mmap
或共享内存段),子进程不会继承这些共享内存区域的共享状态。
- 如果父进程和其他进程共享内存(通过
示例回答
“当使用fork()
创建子进程时,子进程会复制父进程的大部分执行环境,包括用户ID、组ID、文件描述符、信号处理设置、当前工作目录、根目录、环境变量和内存映射。但子进程会有一个新的进程ID和父进程ID,不会继承父进程的文件锁、挂起的信号、统计信息、多线程状态以及互斥锁状态等。由于写时复制机制,内存页面在写入时才会实际复制,从而优化性能。”
通过这样的回答,面试官可以看出你对fork()
系统调用的深刻理解及其在进程创建和资源继承方面的细节。