文章目录
在C++中兼顾频繁插入/删除和查询访问的数据结构选择
当你在C++面试中遇到既需要频繁链表操作(插入/删除)又需要频繁查询访问的场景时,可以考虑以下几种解决方案:
1. 使用哈希表+双向链表的组合结构
这是经典的LRU缓存实现方式,结合了哈希表的O(1)查询和链表的O(1)插入删除:
#include <unordered_map>
#include <list>
template <typename Key, typename Value>
class LinkedHashMap {
private:
typedef typename std::list<std::pair<Key, Value>>::iterator ListIterator;
std::list<std::pair<Key, Value>> itemList;
std::unordered_map<Key, ListIterator> itemMap;
public:
void insert(const Key& key, const Value& value) {
auto it = itemMap.find(key);
if (it != itemMap.end()) {
itemList.erase(it->second);
itemMap.erase(it);
}
itemList.push_front(std::make_pair(key, value));
itemMap[key] = itemList.begin();
}
bool get(const Key& key, Value& value) {
auto it = itemMap.find(key);
if (it == itemMap.end()) return false;
value = it->second->second;
// 移动到前端以表示最近使用
itemList.splice(itemList.begin(), itemList, it->second);
return true;
}
void erase(const Key& key) {
auto it = itemMap.find(key);
if (it != itemMap.end()) {
itemList.erase(it->second);
itemMap.erase(it);
}
}
};
2. 使用STL中的std::unordered_map
+ 自定义链表节点
#include <unordered_map>
template <typename T>
struct ListNode {
T value;
ListNode* prev;
ListNode* next;
};
template <typename Key, typename Value>
class FastAccessList {
private:
std::unordered_map<Key, ListNode<Value>*> map;
ListNode<Value>* head;
ListNode<Value>* tail;
public:
// 实现插入、删除、查找等方法
};
3. 使用跳表(Skip List)
跳表在链表的基础上增加了多级索引,使得查询效率接近O(log n),同时保持插入删除的灵活性:
#include <vector>
#include <cstdlib>
template <typename T>
class SkipNode {
public:
T value;
std::vector<SkipNode*> next;
SkipNode(T val, int level) : value(val), next(level + 1, nullptr) {}
};
template <typename T>
class SkipList {
private:
SkipNode<T>* head;
int maxLevel;
float probability;
int randomLevel() {
int lvl = 0;
while ((rand() % 100) < (probability * 100) && lvl < maxLevel)
lvl++;
return lvl;
}
public:
SkipList(int maxLvl = 16, float p = 0.5) : maxLevel(maxLvl), probability(p) {
head = new SkipNode<T>(T(), maxLevel);
}
// 实现插入、删除、查找等方法
};
4. 使用B+树
B+树在数据库索引中广泛使用,平衡了查询和修改操作:
template <typename Key, typename Value>
class BPlusTree {
// B+树实现较为复杂,这里省略具体实现
};
选择建议
- 如果查询和修改都要求O(1)时间复杂度:选择哈希表+双向链表组合(方案1)
- 如果数据有序且需要范围查询:考虑跳表或B+树
- 如果内存不是主要限制:哈希表通常是最快选择
- 如果数据量非常大:B+树可能是更好的选择
在面试中,解释清楚你的选择理由比具体实现更重要,要能够分析各种数据结构在特定场景下的优缺点。
select文件描述符限制解析
关于select
系统调用的文件描述符限制问题,需要明确以下几点:
核心限制
select
系统调用的限制是文件描述符的值(fd number)不能超过FD_SETSIZE,而不是单纯的管理数量。
在大多数系统上:
FD_SETSIZE
通常定义为1024- 这意味着
select
只能处理文件描述符值在0到1023范围内的文件描述符
详细解释
-
不是管理数量的限制:
select
理论上可以管理少于1024个文件描述符- 但如果其中任何一个文件描述符的数值≥1024,即使总数很少,
select
也无法正常工作
-
底层原因:
select
使用固定大小的位图(fd_set)来表示文件描述符集合- 这个位图的大小由
FD_SETSIZE
决定 - 文件描述符数值直接用作位图的索引
-
实际影响:
int fd = open("file.txt", O_RDONLY); // 假设返回1024 FD_SET(fd, &readfds); // 这将导致越界访问
现代替代方案
由于这个限制,现代程序更常使用:
poll
系统调用 - 没有这个限制epoll
(Linux) - 高效处理大量文件描述符kqueue
(BSD/Mac) - 类似epoll的机制
检查系统实际限制
可以通过以下方式查看系统限制:
# 查看进程级别限制
ulimit -n
# 查看系统级别限制
cat /proc/sys/fs/file-max
在编程中,如果需要处理大量文件描述符,建议使用poll
或epoll
替代select
,它们没有这个1024的文件描述符数值限制。
select
vs epoll
对比
select
和 epoll
都是 Linux 中用于 I/O 多路复用的系统调用,但它们在工作机制和性能上有显著差异。以下是它们的详细对比:
1. 基本工作机制
select
- 轮询机制:每次调用都需要遍历所有被监视的文件描述符
- 固定大小限制:受
FD_SETSIZE
限制(通常1024) - 每次调用都需要重置:每次调用前需要重新设置文件描述符集合
epoll
- 事件驱动:内核维护一个事件表,只返回就绪的文件描述符
- 无固定大小限制:可以处理数万个并发连接
- 无需每次重置:注册一次后,内核会维护事件状态
2. 性能对比
特性 | select | epoll |
---|---|---|
时间复杂度 | O(n) - 线性扫描所有fd | O(1) - 只处理就绪的fd |
最大连接数 | 1024(通常) | 数万(取决于系统内存) |
内存使用 | 固定大小 | 动态增长 |
适用场景 | 少量连接 | 大量并发连接 |
内核支持 | 所有Unix系统 | Linux特有 |
3. 使用方式对比
select
示例
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sockfd, &readfds);
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
int ret = select(sockfd+1, &readfds, NULL, NULL, &timeout);
if (ret > 0 && FD_ISSET(sockfd, &readfds)) {
// 处理就绪的socket
}
epoll
示例
int epfd = epoll_create1(0);
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN;
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
int nfds = epoll_wait(epfd, events, MAX_EVENTS, 5000);
for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == sockfd) {
// 处理就绪的socket
}
}
4. 适用场景
适合使用 select
的情况:
- 需要跨平台兼容性
- 监控的文件描述符数量很少(<1000)
- 简单的应用程序
适合使用 epoll
的情况:
- Linux平台的高性能服务器
- 需要处理大量并发连接(如Web服务器)
- 需要低延迟响应
5. 内核实现差异
select
:每次调用都需要将整个fd集合从用户空间拷贝到内核空间epoll
:epoll_ctl
:注册时拷贝一次epoll_wait
:只返回就绪的事件,无需扫描所有fd
6. 边缘触发(ET)与水平触发(LT)
epoll
特有的两种工作模式:
- 水平触发(LT):类似
select
,只要fd就绪就会通知 - 边缘触发(ET):只在状态变化时通知一次,需要一次性处理所有数据
select
只支持水平触发模式。
总结
对于现代高性能网络应用,在Linux平台上epoll
几乎是必然选择,特别是当需要处理成千上万的并发连接时。而select
因其跨平台特性,在简单应用或需要支持多种Unix系统的场景下仍有使用价值。
非阻塞网络I/O模型
网络I/O模型中有以下几种是非阻塞的:
1. 非阻塞I/O (Non-blocking I/O)
- 特点:设置文件描述符为非阻塞模式后,读写操作会立即返回
- 行为:
- 有数据时返回实际读写的字节数
- 无数据时返回错误(EAGAIN/EWOULDBLOCK)而不阻塞
- 优点:单线程可以处理多个连接
- 缺点:需要不断轮询检查状态,CPU利用率高
// 设置非阻塞模式示例
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
2. I/O多路复用 (I/O Multiplexing)
虽然select/poll/epoll本身是阻塞的,但结合非阻塞fd使用时可实现非阻塞效果:
- select/poll/epoll + 非阻塞fd
- 特点:监控多个fd,当其中任何一个就绪时返回
- 优点:比纯非阻塞I/O更高效,减少无效轮询
3. 信号驱动I/O (Signal-driven I/O)
- 特点:通过信号(SIGIO)通知I/O就绪
- 行为:设置信号处理程序后,内核在fd就绪时发送信号
- 优点:不需要主动轮询
- 缺点:信号处理复杂,不适合高吞吐场景
// 信号驱动I/O设置示例
signal(SIGIO, sigio_handler);
fcntl(fd, F_SETOWN, getpid());
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC);
4. 异步I/O (Asynchronous I/O, AIO)
- 特点:真正的异步非阻塞模型
- 行为:发起I/O操作后立即返回,内核完成操作后通知
- 优点:完全非阻塞,最理想的异步模型
- 缺点:Linux原生AIO实现不完善,Windows IOCP更成熟
// Linux AIO示例
struct aiocb cb = {
.aio_fildes = fd,
.aio_buf = buf,
.aio_nbytes = size,
.aio_offset = 0
};
aio_read(&cb);
对比总结
模型 | 阻塞点 | 是否需要轮询 | 典型实现 |
---|---|---|---|
非阻塞I/O | 无 | 需要 | fcntl(O_NONBLOCK) |
I/O多路复用 | 在select/epoll调用 | 不需要(内核通知) | select/poll/epoll |
信号驱动I/O | 无 | 不需要 | fcntl(O_ASYNC) |
异步I/O | 无 | 不需要 | Linux AIO, IOCP |
在实际开发中,非阻塞I/O + I/O多路复用(epoll) 是Linux下高并发网络编程的主流选择,而Windows平台则多采用IOCP模型。纯异步I/O在Linux上的实现还不够完善,应用相对较少。
在Socket编程中,TCP连接的建立过程(三次握手)是由操作系统内核自动处理的,应用层无法直接监听每次握手的完成回调。以下是关键点的详细解答:
三次握手的回调是否可以监听
1. 三次握手的回调机制
-
标准Socket API不提供握手阶段的回调
TCP三次握手完全由内核协议栈处理,对应用程序透明。开发者无法直接获取每次握手完成的通知(如SYN发送、SYN-ACK接收、ACK发送的独立回调)。 -
应用层仅感知最终结果
通过connect()
函数的返回值或异步通知机制,应用层只能知道连接是否整体成功或失败,无法感知中间每一步握手的状态。
2. 连接建立的监控方式
(1) 阻塞模式下的connect()
- 行为:调用
connect()
后,线程会阻塞直到三次握手完成(成功或超时)。 - 返回值:
- 成功返回
0
- 失败返回
-1
,错误码为errno
(如ETIMEDOUT
,ECONNREFUSED
)
- 成功返回
// 阻塞模式示例
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr = {...};
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("Connection failed");
}
(2) 非阻塞模式下的connect()
-
行为:调用立即返回,连接在后台进行。
-
返回值:
- 若连接立即成功(本地回环等特殊情况):返回
0
- 否则返回
-1
,错误码为EINPROGRESS
(表示连接正在进行中)
- 若连接立即成功(本地回环等特殊情况):返回
-
检测连接完成:需通过I/O多路复用(如
select
,poll
,epoll
)监听socket的可写事件:// 非阻塞模式示例 fcntl(sockfd, F_SETFL, O_NONBLOCK); connect(sockfd, ...); // 返回-1且errno=EINPROGRESS fd_set writefds; FD_ZERO(&writefds); FD_SET(sockfd, &writefds); struct timeval timeout = {5, 0}; // 5秒超时 int ret = select(sockfd+1, NULL, &writefds, NULL, &timeout); if (ret > 0 && FD_ISSET(sockfd, &writefds)) { // 检查是否真正成功 int error; socklen_t len = sizeof(error); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len); if (error == 0) { // 连接成功 } else { // 连接失败 } }
3. 底层握手过程的间接监控
虽然应用层无法直接监听三次握手,但可通过以下方式间接分析:
-
抓包工具
使用tcpdump
或Wireshark捕获网络包,观察SYN、SYN-ACK、ACK的交互。tcpdump -i eth0 'tcp port 80 and (tcp-syn or tcp-ack)'
-
内核调试
通过strace
跟踪系统调用,观察connect()
的行为:strace -e trace=network ./your_program
-
自定义TCP栈(高级)
修改内核或使用用户态TCP协议栈(如DPDK),但这已超出常规Socket编程范畴。
4. connect()
的阻塞性
-
阻塞模式:
线程阻塞,直到三次握手完成(成功或超时)。超时时间由内核参数net.ipv4.tcp_syn_retries
控制。 -
非阻塞模式:
立即返回,需通过I/O多路复用检测连接状态。超时由应用层控制(如select()
的超时参数)。
5. 总结
特性 | 说明 |
---|---|
三次握手回调 | 内核自动处理,应用层无法监听每一步 |
连接成功通知 | 通过connect() 返回值或I/O多路复用检测 |
阻塞 vs 非阻塞 | 阻塞模式直接等待结果;非阻塞模式需异步检测 |
调试手段 | 抓包工具或系统调用跟踪 |
6. 最佳实践
- 非阻塞+Epoll:
高并发场景下使用非阻塞socket配合epoll
,实现高效连接管理。 - 超时控制:
对非阻塞连接设置合理的超时时间(如3秒),避免无限等待。 - 错误处理:
始终检查getsockopt(SO_ERROR)
以确认连接最终状态。
// 非阻塞连接 + epoll示例
int epoll_fd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLOUT; // 监听可写事件(连接完成)
ev.data.fd = sockfd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockfd, &ev);
struct epoll_event events[1];
int n = epoll_wait(epoll_fd, events, 1, 3000); // 3秒超时
if (n > 0) {
// 检查连接是否成功
}
通过合理设计,即使无法监听每次握手细节,也能构建高效可靠的TCP连接机制。
connect的阻塞性
connect()
是 TCP socket 编程中用于建立连接的关键函数,其阻塞行为对网络应用的性能和可靠性有重要影响。下面我将从多个维度详细分析 connect()
的阻塞特性。
1. 基本阻塞行为
阻塞模式 (默认)
-
行为特征:
- 调用后线程会完全阻塞,直到三次握手完成(成功或失败)
- 阻塞时间取决于网络状况和内核参数
- 典型阻塞时间范围:几百毫秒到数分钟不等
-
典型代码:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in serv_addr = {...}; // 设置服务器地址
// 阻塞式connect
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1) {
perror("connect failed");
// 处理错误
}
非阻塞模式
-
行为特征:
- 调用后立即返回,不等待连接完成
- 返回值为 -1 且 errno 为 EINPROGRESS 表示连接正在进行
- 需要通过其他机制检测连接状态
-
典型代码:
// 设置为非阻塞模式
fcntl(sockfd, F_SETFL, O_NONBLOCK);
int ret = connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
if (ret == -1 && errno != EINPROGRESS) {
perror("connect failed immediately");
// 处理错误
}
2. 超时控制机制
阻塞模式下的超时
-
内核控制参数:
net.ipv4.tcp_syn_retries
:SYN重试次数(默认通常为6)net.ipv4.tcp_synack_retries
:SYN-ACK重试次数- 总超时时间 ≈ (2^n - 1) × 初始超时(n为重试次数)
-
查看当前设置:
sysctl net.ipv4.tcp_syn_retries
非阻塞模式下的超时
- 应用层控制:
- 使用 select/poll/epoll 设置超时
- 典型实现:
struct timeval timeout = {3, 0}; // 3秒超时
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(sockfd, &writefds);
int selret = select(sockfd+1, NULL, &writefds, NULL, &timeout);
if (selret == 0) {
// 超时处理
close(sockfd);
// ...
}
3. 连接状态检测
非阻塞连接完成检测
- select/poll/epoll 检测可写事件
- 验证实际连接状态:
int error;
socklen_t len = sizeof(error);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len);
if (error == 0) {
// 连接成功
} else {
// 连接失败
}
常见错误码
错误码 | 含义 |
---|---|
ETIMEDOUT | 连接超时 |
ECONNREFUSED | 目标主动拒绝 |
ENETUNREACH | 网络不可达 |
EHOSTUNREACH | 主机不可达 |
4. 性能与资源考量
阻塞模式的缺点
- 线程挂起:每个连接占用一个线程,高并发时资源消耗大
- 缺乏灵活性:难以实现连接超时、取消等高级功能
非阻塞模式的优势
- 资源高效:单线程可管理大量连接
- 灵活控制:可实现精确超时、批量连接等
- 响应快速:立即返回不阻塞主线程
5. 高级应用场景
异步连接模式
// 使用epoll管理多个非阻塞连接
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLOUT | EPOLLET; // 边缘触发模式
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
// 事件循环中处理
int n = epoll_wait(epfd, events, MAX_EVENTS, timeout);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLOUT) {
// 检查连接状态
int error;
getsockopt(events[i].data.fd, SOL_SOCKET, SO_ERROR, &error, &len);
// ...处理...
}
}
连接超时优化
// 带精确超时的非阻塞连接
struct timespec start, now;
clock_gettime(CLOCK_MONOTONIC, &start);
while (1) {
clock_gettime(CLOCK_MONOTONIC, &now);
long elapsed = (now.tv_sec - start.tv_sec) * 1000 +
(now.tv_nsec - start.tv_nsec) / 1000000;
if (elapsed > 3000) { // 3秒超时
close(sockfd);
break;
}
// 检查连接状态...
usleep(10000); // 10ms间隔检查
}
6. 跨平台注意事项
- Windows:使用
WSAConnect
和WSAEventSelect
- 非阻塞行为在不同OS上可能有细微差异
- 错误码可能因平台而异
7. 最佳实践建议
- 生产环境推荐使用非阻塞模式
- 设置合理的连接超时(通常2-5秒)
- 实现连接状态监控和错误恢复机制
- 高并发场景使用I/O多路复用
- 考虑使用连接池管理已建立的连接
通过合理利用 connect()
的阻塞/非阻塞特性,可以构建出高效可靠的网络应用。非阻塞模式虽然实现稍复杂,但在性能和资源利用率上的优势使其成为现代网络编程的首选。
unique_ptr
的使用场景和析构机制
unique_ptr
的主要使用场景
unique_ptr
是C++11引入的智能指针,主要用于以下场景:
-
独占资源所有权管理
- 当需要确保一个资源只有一个所有者时
- 替代原始指针的裸内存管理,防止内存泄漏
-
工厂函数返回值
std::unique_ptr<MyClass> createObject() { return std::unique_ptr<MyClass>(new MyClass()); }
-
作为类成员变量
- 当类需要独占某个资源时
- 确保资源随对象销毁而自动释放
-
多态对象管理
std::unique_ptr<Base> p = std::make_unique<Derived>();
-
数组管理
std::unique_ptr<int[]> arr(new int[10]);
-
资源转移场景
- 需要明确转移资源所有权时
unique_ptr
的自动析构机制
会自动析构吗?
是的,unique_ptr
会自动调用所管理对象的析构函数并释放内存。
析构时机
unique_ptr
的析构发生在以下情况:
-
离开作用域时
{ std::unique_ptr<MyClass> ptr(new MyClass()); // ptr在这里有效 } // 离开作用域,ptr自动析构,管理的对象被销毁
-
被显式reset或赋值为nullptr时
ptr.reset(); // 立即析构管理对象 ptr = nullptr; // 同上,析构管理对象
-
被另一个
unique_ptr
替换时ptr = std::make_unique<MyClass>(); // 原对象先被析构
-
所在对象被销毁时(当
unique_ptr
是类成员)class Container { std::unique_ptr<MyClass> member; public: ~Container() { // member会在Container析构时自动析构 } };
-
异常发生时
void foo() { std::unique_ptr<MyClass> ptr(new MyClass()); throw std::runtime_error("error"); // 即使抛出异常,ptr也会被正确析构 }
注意事项
-
不能复制,只能移动(体现了独占所有权的语义)
auto ptr1 = std::make_unique<MyClass>(); auto ptr2 = std::move(ptr1); // 正确:所有权转移 // auto ptr3 = ptr1; // 错误:不能复制
-
自定义删除器:可以指定特殊的资源释放方式
auto fileDeleter = [](FILE* f) { fclose(f); }; std::unique_ptr<FILE, decltype(fileDeleter)> filePtr(fopen("test.txt", "r"), fileDeleter);
-
优先使用
make_unique
(C++14引入)auto ptr = std::make_unique<MyClass>(); // 更安全,避免内存泄漏
unique_ptr
是轻量级的智能指针,几乎无额外开销,是大多数独占所有权场景的首选。