Skip to content

校招面经系列1-腾讯天美C++后台开发一面面经

来源:https://www.nowcoder.com/discuss/631136285108822016

算法题

1、实现memmove

memmove 用于在内存中移动数据。

C++实现memmove

c++
#include <iostream>
#include <cstring>  // for size_t

// 自定义的 memmove 实现
void* custom_memmove(void* dest, const void* src, size_t n) {
    // 转换成 char* 进行字节操作
    char* d = static_cast<char*>(dest);
    const char* s = static_cast<const char*>(src);

    // 判断是否有重叠
    if (d == s) {
        // 如果源地址和目的地址相同,直接返回
        return dest;
    } else if (d < s || d >= s + n) {
        // 如果没有重叠,从前向后复制
        while (n--) {
            *d++ = *s++;
        }
    } else {
        // 如果有重叠,从后向前复制
        d += n;
        s += n;
        while (n--) {
            *(--d) = *(--s);
        }
    }

    return dest;
}

// 测试用例
int main() {
    char str1[] = "memmove can be very useful......";
    custom_memmove(str1 + 20, str1 + 15, 11);
    std::cout << "Test 1: " << str1 << std::endl;  // 输出: "memmove can be very very useful."

    char str2[] = "abcdefghijklmnopqrstuvwxyz";
    custom_memmove(str2 + 5, str2, 10);
    std::cout << "Test 2: " << str2 << std::endl;  // 输出: "abcdeabcdefghijklmnopqrstuvwxyz"

    return 0;
}

2、字符翻转 hello world, god bless you -> world hello, you bless god

思路

  1. 将字符串分割成单词:首先,我们需要将字符串按照空格分割成单词。
  2. 将单词顺序反转:然后,将这些单词的顺序进行反转。
  3. 重新拼接成字符串:最后,将反转后的单词重新拼接成一个字符串。

参考代码

c++
#include <iostream>
#include <vector>
#include <sstream>
#include <algorithm>

// 辅助函数:分割字符串为单词列表
std::vector<std::string> splitWords(const std::string& str) {
    std::vector<std::string> words;
    std::istringstream stream(str);
    std::string word;
    while (stream >> word) {
        words.push_back(word);
    }
    return words;
}

// 辅助函数:分割字符串为子字符串列表,按指定字符分割
std::vector<std::string> splitByDelimiter(const std::string& str, char delimiter) {
    std::vector<std::string> parts;
    std::istringstream stream(str);
    std::string part;
    while (std::getline(stream, part, delimiter)) {
        parts.push_back(part);
    }
    return parts;
}

// 主函数:反转字符串中的单词顺序
std::string reverseWordsInParts(const std::string& str) {
    // 第一步:将字符串按逗号分割成部分
    std::vector<std::string> parts = splitByDelimiter(str, ',');

    // 第二步:反转每个部分中的单词顺序
    for (std::string& part : parts) {
        std::vector<std::string> words = splitWords(part);
        std::reverse(words.begin(), words.end());
        part.clear();
        for (size_t i = 0; i < words.size(); ++i) {
            part += words[i];
            if (i != words.size() - 1) {
                part += " ";
            }
        }
    }

    // 第三步:将部分按逗号拼接成最终字符串
    std::string result;
    for (size_t i = 0; i < parts.size(); ++i) {
        result += parts[i];
        if (i != parts.size() - 1) {
            result += ", ";
        }
    }
    return result;
}

int main() {
    std::string input = "hello world, god bless you";
    std::string output = reverseWordsInParts(input);
    std::cout << output << std::endl; // 输出: "world hello, you bless god"
    return 0;
}

语言

1、new 和 malloc具体做了啥?然后又深入问这过程C++做了啥C++的内存管理?

new 操作符

new 是C++的操作符,用于在堆上分配内存。它不仅分配内存,还调用构造函数来初始化对象。使用new分配的内存需要使用delete来释放,delete不仅释放内存,还调用析构函数来清理对象。

具体步骤:
  1. 分配内存:调用操作系统的内存管理器来分配足够的内存以容纳指定类型的对象。
  2. 调用构造函数:在分配的内存上调用对象的构造函数来初始化对象。
c++
MyClass* obj = new MyClass(); // 分配内存并调用构造函数
内部过程:
  1. 调用operator new:这是一个全局函数,类似于malloc,用于分配原始内存块。
  2. 调用构造函数:在分配的内存上使用对象的构造函数来初始化对象。
c++
void* raw_memory = operator new(sizeof(MyClass)); // 分配内存
MyClass* obj = new (raw_memory) MyClass(); // 在内存上调用构造函数

malloc 函数

malloc 是C标准库中的函数,用于分配指定大小的内存块。它只分配内存,不会调用构造函数来初始化对象。使用malloc分配的内存需要使用free来释放。

具体步骤:

分配内存:调用操作系统的内存管理器来分配指定大小的内存块。

c++
MyClass* obj = (MyClass*)malloc(sizeof(MyClass)); // 分配内存,但不调用构造函数
内部过程:

调用操作系统的内存管理函数:如brksbrkmmap,这些函数负责实际的内存分配。

c++
void* memory = malloc(sizeof(MyClass)); // 分配内存

C++内存管理?

1. 内存分区

C++程序运行时的内存大致分为以下几个区域:

  • 代码区(Text Segment):存储程序的机器代码。
  • 全局/静态区(Data Segment):存储全局变量和静态变量。该区分为已初始化数据段(.data)和未初始化数据段(.bss)。
  • 堆区(Heap):动态内存分配区,程序运行时通过newnew[]malloc等函数在此区域分配内存,释放时使用deletedelete[]free等函数。
  • 栈区(Stack):函数调用时用于存储局部变量、函数参数、返回地址等。栈上的内存由编译器自动管理,函数返回时自动释放。
2. 栈内存管理

栈内存的分配和释放由编译器自动完成,速度非常快,通常用于存储局部变量。栈的特点是LIFO(后进先出),每次函数调用会在栈上创建一个新的栈帧,函数返回时该栈帧会自动销毁。

c++
void function() {
    int a = 10; // a 在栈上分配
    // 栈上的内存自动释放
}
3. 堆内存管理

堆内存是由程序员手动管理的,分配和释放都需要显式地调用相应的函数。使用newdelete进行对象的分配和释放时,会自动调用构造函数和析构函数。

堆内存的分配和释放:
c++
int* ptr = new int(5); // 分配单个整数
delete ptr; // 释放单个整数

int* arr = new int[10]; // 分配数组
delete[] arr; // 释放数组
4. 内存泄漏

如果动态分配的内存没有被正确释放,就会导致内存泄漏。内存泄漏会耗尽系统的内存资源,最终可能导致程序崩溃。因此,确保每个new都有相应的delete,每个malloc都有相应的free是非常重要的。

内存泄漏示例:
c++
void function() {
    int* ptr = new int(5);
    // 忘记 delete ptr;
}
5. 智能指针

C++11引入了智能指针,自动管理动态内存,防止内存泄漏。智能指针主要有三种:std::unique_ptrstd::shared_ptrstd::weak_ptr

std::unique_ptr

std::unique_ptr是独占所有权的智能指针,不能被复制,只能被移动。

c++
std::unique_ptr<int> ptr1(new int(5));
std::unique_ptr<int> ptr2 = std::move(ptr1); // 转移所有权
std::shared_ptr

std::shared_ptr是共享所有权的智能指针,可以被多个指针共享。

c++
std::shared_ptr<int> ptr1(new int(5));
std::shared_ptr<int> ptr2 = ptr1; // 共享所有权
std::weak_ptr

std::weak_ptr是一种不增加引用计数的智能指针,用于解决循环引用问题。

c++
std::shared_ptr<int> sp = std::make_shared<int>(5);
std::weak_ptr<int> wp = sp; // wp 不增加引用计数
6. 内存池

为了提高内存分配和释放的效率,C++可以使用内存池(Memory Pool)。内存池预先分配一块大内存,然后根据需要分配和释放小块内存。内存池可以显著减少内存分配和释放的开销。

内存池示例:
c++
class MemoryPool {
public:
    MemoryPool(size_t size) {
        pool = malloc(size);
    }

    ~MemoryPool() {
        free(pool);
    }

    void* allocate(size_t size) {
        // 简单的内存池实现示例
        void* ptr = pool;
        pool = (char*)pool + size;
        return ptr;
    }

private:
    void* pool;
};

2、回答系统调用sbrk然后说了一点操作系统的东西,应该是问的C++的内存模型吧?

brksbrk 是 Unix 系统中用于动态内存管理的系统调用。它们控制进程的数据段的末尾,以便动态调整程序数据段的大小,从而实现动态内存分配。

基本概念

  • brk: 设置程序的数据段末尾。
  • sbrk: 调整数据段末尾的增量(向上或向下)。

sbrk 的使用

c++
void* sbrk(intptr_t increment);
  • 参数:increment: 调整数据段末尾的字节数。可以为正(增加数据段大小)或负(减少数据段大小)。
  • 返回值:
    • 成功时,返回数据段末尾的旧地址。
    • 失败时,返回 (void *)-1,并设置 errno

工作原理

在 Unix 系统中,进程的内存布局大致如下:

-------------------------
|        Stack          |
-------------------------
|                       |
|        ...            |
|                       |
-------------------------
|        Heap           |
-------------------------
|   BSS (uninitialized) |
-------------------------
|   Data (initialized)  |
-------------------------
|       Text            |
-------------------------
  • Text Segment: 存放代码。
  • Data Segment: 存放已初始化的全局变量。
  • BSS Segment: 存放未初始化的全局变量。
  • Heap: 动态分配的内存。
  • Stack: 函数调用栈。

brksbrk 调整的是数据段(包括堆)的大小。通过调整数据段的末尾位置,可以增加或减少堆的大小。

示例代码

c++
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 获取当前数据段末尾位置
    void* initial_break = sbrk(0);
    printf("Initial break: %p\n", initial_break);

    // 分配 10 KB 内存
    void* new_break = sbrk(10 * 1024);
    if (new_break == (void*)-1) {
        perror("sbrk");
        exit(1);
    }

    printf("New break after allocation: %p\n", sbrk(0));

    // 释放分配的内存
    new_break = sbrk(-10 * 1024);
    if (new_break == (void*)-1) {
        perror("sbrk");
        exit(1);
    }

    printf("New break after deallocation: %p\n", sbrk(0));

    return 0;
}

3、多态?

  1. 编译时多态性(静态多态性):通过函数重载和运算符重载实现。这种多态性在编译时就确定了调用哪个函数。

函数重载示例:

c++
#include <iostream>

class Math {
public:
    int add(int a, int b) {
        return a + b;
    }

    double add(double a, double b) {
        return a + b;
    }
};

int main() {
    Math math;
    std::cout << "Int add: " << math.add(3, 4) << std::endl;       // 输出: Int add: 7
    std::cout << "Double add: " << math.add(3.5, 4.5) << std::endl; // 输出: Double add: 8
    return 0;
}
  1. 运行时多态性(动态多态性):运行时多态性通过继承和虚函数实现。这种多态性在运行时根据对象的类型动态决定调用哪个函数。

运行时多态性示例

c++
#include <iostream>

// 基类
class Shape {
public:
    // 虚函数
    virtual void draw() const {
        std::cout << "Drawing Shape" << std::endl;
    }

    // 虚析构函数
    virtual ~Shape() {}
};

// 派生类:Circle
class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing Circle" << std::endl;
    }
};

// 派生类:Rectangle
class Rectangle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing Rectangle" << std::endl;
    }
};

int main() {
    // 创建对象
    Shape* shape1 = new Circle();
    Shape* shape2 = new Rectangle();

    // 调用虚函数
    shape1->draw(); // 输出: Drawing Circle
    shape2->draw(); // 输出: Drawing Rectangle

    // 释放内存
    delete shape1;
    delete shape2;

    return 0;
}

虚函数(virtual function):在基类中使用 virtual 关键字声明的函数,可以在派生类中被重写。调用虚函数时,将根据实际对象的类型来确定调用哪个版本的函数。

纯虚函数(pure virtual function):如果在基类中声明一个函数为纯虚函数,即使用 = 0,那么该基类就是抽象类,不能创建实例。派生类必须实现纯虚函数。

4、构造函数可以是虚函数吗,析构函数呢?

C++中,构造函数不能是虚函数,而析构函数可以并且通常应该是虚函数,特别是在使用多态的时候。

为什么构造函数不能是虚函数?

  1. 对象的构造顺序:构造函数用于初始化对象的成员变量和基类部分。如果构造函数是虚函数,在对象创建过程中可能还没有足够的信息来确定哪个构造函数应该被调用。对象的类型在构造期间还没有完全确定,因此无法进行虚函数表的查找。
  2. 对象的创建过程:在C++中,对象的创建分为几个阶段:分配内存、调用构造函数、初始化成员变量等。虚函数机制依赖于对象的虚函数表,而虚函数表是在对象构造之后才设置的。因此,在构造函数调用期间,虚函数机制还不能正常工作。

析构函数为什么可以是虚函数?

析构函数通常应该是虚函数,以确保正确的对象销毁过程。在多态情况下,如果基类的析构函数不是虚函数,销毁派生类对象时可能不会调用派生类的析构函数,导致资源泄漏或未能正确释放资源。

示例:虚析构函数的必要性
c++
#include <iostream>

class Base {
public:
    Base() { std::cout << "Base Constructor\n"; }
    virtual ~Base() { std::cout << "Base Destructor\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived Constructor\n"; }
    ~Derived() { std::cout << "Derived Destructor\n"; }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // 调用虚析构函数,正确调用 Derived 和 Base 的析构函数
    return 0;
}

输出结果:

c++
Base Constructor
Derived Constructor
Derived Destructor
Base Destructor

构造函数和析构函数的详细说明

构造函数
  • 作用:初始化对象的成员变量和基类部分。
  • 特点:不能是虚函数。可以被重载(多个构造函数),但不能被继承。
  • 调用顺序:基类构造函数先于派生类构造函数被调用。成员对象的构造函数在类构造函数之前被调用。
析构函数
  • 作用:清理对象的资源,释放内存。
  • 特点:可以是虚函数。通常在基类中定义为虚函数,以确保多态情况下正确调用派生类析构函数。
  • 调用顺序:派生类析构函数先于基类析构函数被调用。成员对象的析构函数在类析构函数之后被调用。
示例:非虚析构函数的错误示例
c++
#include <iostream>

class Base {
public:
    Base() { std::cout << "Base Constructor\n"; }
    ~Base() { std::cout << "Base Destructor\n"; }
};

class Derived : public Base {
public:
    Derived() { std::cout << "Derived Constructor\n"; }
    ~Derived() { std::cout << "Derived Destructor\n"; }
};

int main() {
    Base* ptr = new Derived();
    delete ptr; // 错误:只调用 Base 的析构函数,未调用 Derived 的析构函数
    return 0;
}

输出结果:

c++
Base Constructor
Derived Constructor
Base Destructor

5、C++11、14、17有哪些特性?auto、智能指针?

C++11 特性

  1. 自动类型推导 (auto)

    c++
    auto x = 5; // x is of type int
  2. 范围for循环 (range-based for loop)

    c++
    std::vector<int> vec = {1, 2, 3, 4};
    for (auto& v : vec) {
        std::cout << v << std::endl;
    }
  3. lambda表达式 (lambda expressions)

    c++
    auto add = [](int a, int b) { return a + b; };
    std::cout << add(2, 3) << std::endl; // Outputs: 5
  4. 智能指针 (smart pointers)

    c++
    std::unique_ptr<int> p1(new int(5));
    std::shared_ptr<int> p2 = std::make_shared<int>(10);
  5. 右值引用和移动语义 (rvalue references and move semantics)

    c++
    std::vector<int> v1 = {1, 2, 3};
    std::vector<int> v2 = std::move(v1); // v1 is now empty, v2 has the data
  6. std::thread和多线程库 (std::thread and multithreading library)

    c++
    std::thread t([]{ std::cout << "Hello from thread!" << std::endl; });
    t.join();
  7. 初始化列表 (initializer lists)

    c++
    std::vector<int> vec = {1, 2, 3, 4};
  8. 类型别名 (type aliases)

    c++
    using int_vec = std::vector<int>;
  9. 静态断言 (static assertions)

    c++
    static_assert(sizeof(int) == 4, "int size is not 4 bytes");
  10. 改进的枚举类型 (enum classes)

    c++
    enum class Color { Red, Green, Blue };
    Color c = Color::Red;

C++14 特性

  1. 通用lambda捕获 (generic lambda captures)

    c++
    auto lambda = [](auto x, auto y) { return x + y; };
    std::cout << lambda(1, 2) << std::endl; // Outputs: 3
  2. 返回类型推导 (return type deduction)

    c++
    auto func() {
        return 42;
    }
  3. 二进制字面值 (binary literals)

    c++
    int bin = 0b101010;
  4. 泛型lambda (generic lambdas)

    c++
    auto glambda = [](auto a, auto b) { return a < b; };
    bool b = glambda(3, 3.14);
  5. 标准库的make_unique (std::make_unique)

    c++
    auto p = std::make_unique<int>(42);
  6. decltype(auto)

    c++
    auto func() -> decltype(auto) {
        int x = 0;
        return (x); // decltype(auto) preserves the type and value category of x
    }

C++17 特性

  1. 结构化绑定 (structured bindings)

    c++
    std::tuple<int, double, std::string> tpl(1, 2.3, "text");
    auto [i, d, s] = tpl;
  2. if和switch的初始化 (if and switch with initialization)

    c++
    if (auto it = m.find(key); it != m.end()) {
        // use it
    }
    
    switch (auto x = foo(); x) {
        // use x
    }
  3. 折叠表达式 (fold expressions)

    c++
    template<typename... Args>
    auto sum(Args... args) {
        return (args + ...);
    }
  4. 内联变量 (inline variables)

    c++
    inline int x = 10;
  5. constexpr if

    c++
    template<typename T>
    void func(T t) {
        if constexpr (std::is_integral<T>::value) {
            // do something for integral types
        } else {
            // do something for non-integral types
        }
    }
  6. std::optional

    c++
    std::optional<int> opt = 42;
    if (opt) {
        std::cout << *opt << std::endl;
    }
  7. std::variant

    c++
    std::variant<int, float> v;
    v = 42;
    v = 3.14f;
  8. std::any

    c++
    std::any a = 1;
    a = std::string("hello");
  9. 文件系统库 (filesystem library)

    c++
    #include <filesystem>
    namespace fs = std::filesystem;
    for (const auto& entry : fs::directory_iterator(".")) {
        std::cout << entry.path() << std::endl;
    }

6、auto怎么实现的?

允许编译器在变量声明时根据初始化表达式自动推断变量的类型,从而简化代码,提高可读性,并减少类型错误的可能性。

实现原理

编译器在编译时通过分析初始化表达式来推断变量的类型。具体过程如下:

  1. 语法解析: 编译器首先解析声明语句中的初始化表达式。
  2. 类型推导: 根据初始化表达式的类型,编译器推导出 auto 应该替换的实际类型。
  3. 类型替换: 编译器将推导出的类型替换 auto,从而完成变量的类型声明。

八股

1、Linux怎么查看空闲内存?

1. free 命令

free 命令是最常用的查看内存使用情况的工具之一。它显示了系统中所有内存(物理内存和交换内存)的详细信息。

free -h

输出示例:

              total        used        free      shared  buff/cache   available
Mem:           7.8G        2.3G        3.2G        341M        2.3G        4.9G
Swap:          2.0G          0B        2.0G

-h 选项表示以人类可读的格式显示内存大小(如 KB、MB、GB)。

2. tophtop 命令

tophtop 命令提供了实时系统监控功能,可以显示当前系统的内存使用情况。

  • top 命令

    top

    按下 M 键可以按照内存使用排序。

  • htop 命令(需要安装):

    htop

    htop 提供了一个更友好的用户界面,可以更直观地查看内存使用情况。

3. vmstat 命令

vmstat 命令提供了关于系统虚拟内存、进程、CPU 活动的统计信息。

vmstat -s

输出示例:

  7979392 K total memory
  2378292 K used memory
  3246508 K active memory
  1597344 K inactive memory
  3647688 K free memory
   252164 K buffer memory
  1893660 K swap cache
  2097148 K total swap
        0 K used swap
  2097148 K free swap
...

4. /proc/meminfo 文件

/proc/meminfo 文件包含了关于系统内存使用的详细信息。

cat /proc/meminfo

输出示例:

MemTotal:        7979392 kB
MemFree:         3647688 kB
MemAvailable:    4972932 kB
Buffers:          252164 kB
Cached:          2117940 kB
SwapCached:            0 kB
Active:          3246508 kB
Inactive:        1597344 kB
SwapTotal:       2097148 kB
SwapFree:        2097148 kB
...

5. grepawk 结合使用

可以结合 grepawk 命令从 /proc/meminfo 中提取特定的信息。

grep MemFree /proc/meminfo | awk '{print $2}'

以上命令将返回系统中空闲内存的数量(以 KB 为单位)。

6. watch 命令

可以使用 watch 命令定期运行 free 命令来监控内存使用情况。

watch -n 1 free -h

2、进程间通信方式?

3、TCP四次挥手

四次挥手

  1. 第一次挥手(FIN-1):
    • 客户端发送一个 FIN 报文段给服务器,表示客户端已经没有数据要发送了,请求关闭连接。
    • 客户端进入 FIN_WAIT_1 状态,等待服务器的确认。
  2. 第二次挥手(ACK):
    • 服务器收到客户端的 FIN 报文段后,发送一个 ACK 报文段作为应答,表示已经接收到了客户端的关闭请求。
    • 服务器进入 CLOSE_WAIT 状态,等待自己的数据发送完毕。
  3. 第三次挥手(FIN-2):
    • 服务器发送一个 FIN 报文段给客户端,表示服务器也没有数据要发送了,请求关闭连接。
    • 服务器进入 LAST_ACK 状态,等待客户端的确认。
  4. 第四次挥手(ACK):
    • 客户端收到服务器的 FIN 报文段后,发送一个 ACK 报文段作为应答,表示已经接收到了服务器的关闭请求。
    • 客户端进入 TIME_WAIT 状态,等待可能出现的延迟数据。
    • 服务器收到客户端的 ACK 报文段后,完成关闭,进入 CLOSED 状态。
    • 客户端在 TIME_WAIT 状态结束后,关闭连接,进入 CLOSED 状态。

4、Timewait有什么用?

1. 确保连接的所有数据包正确到达

当一方主动关闭连接(发送 FIN 包),对方会发送一个 ACK 包进行确认。这时,连接进入 TIME_WAIT 状态,保持一段时间(通常是 2 * 最大报文段寿命(Maximum Segment Lifetime, MSL))。这段时间内,如果有任何未到达的包仍在网络中,能确保它们有机会到达并被正确处理。

2. 防止旧数据包干扰新连接

TCP 连接是通过 IP 地址和端口号来唯一标识的。如果立即重用相同的 IP 地址和端口号建立新的连接,可能会遇到上一个连接的旧数据包(网络中的延迟包)导致数据混乱。TIME_WAIT 确保在特定时间内,旧连接的所有数据包都被清除,防止旧数据包影响新连接。

3. 正确处理连接关闭的四次握手

在 TCP 连接关闭的四次握手过程中:

  1. 主动关闭方发送 FIN
  2. 被动关闭方回 ACK
  3. 被动关闭方发送 FIN
  4. 主动关闭方回 ACK,然后进入 TIME_WAIT 状态。

TIME_WAIT 确保主动关闭方的最后一个 ACK 能够到达被动关闭方,并处理任何可能的重传 FIN 包。

具体实现中的 TIME_WAIT

在 Linux 系统中,可以通过以下方式查看 TCP 连接状态:

netstat -an | grep TIME_WAIT

影响及优化

TIME_WAIT 状态会占用一些系统资源(如端口)。在高并发服务器上,可能会导致端口耗尽。常见的优化方法包括:

  1. 减少 TIME_WAIT 的持续时间:调整内核参数(如 Linux 中的 tcp_fin_timeout)。
  2. 启用端口重用:使用 SO_REUSEADDRSO_REUSEPORT 选项允许端口重用。
例子:

在 Linux 中,可以通过修改 sysctl 参数来调整 TIME_WAIT 相关的设置:

echo 30 > /proc/sys/net/ipv4/tcp_fin_timeout  # 设置 `TIME_WAIT` 超时为 30 秒

启用端口重用:

int optval = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval));

5、epoll和select相比有哪些优势?

1. 性能和可扩展性

select 的缺点:
  • 线性扫描:select 需要对所有的文件描述符进行线性扫描,当文件描述符数量较多时,效率低下。
  • 文件描述符限制:select 监视的文件描述符数量有限,通常为 1024 或 2048(可以通过修改内核参数增加,但会影响性能)。
epoll 的优势:
  • 事件驱动:epoll 是事件驱动的,而不是线性扫描。只有活跃的文件描述符才会被处理,极大地提高了效率。
  • 无上限:epoll 可以监视的文件描述符数量理论上没有上限(受限于系统内存),适用于大量文件描述符的场景。

2. 内存拷贝

select 的缺点:

用户态和内核态拷贝:每次调用 select 都需要将文件描述符集从用户态拷贝到内核态,然后再拷贝回用户态,导致性能损耗。

epoll 的优势:

一次注册,多次使用:epoll 通过 epoll_ctl 注册文件描述符到内核,只需要一次系统调用即可,然后可以多次使用,不需要频繁的拷贝操作。

3. 操作简单

select 的缺点:

复杂性:select 的使用需要手动设置和重置文件描述符集,编程复杂度较高。

epoll 的优势:

接口简洁:epoll 提供了 epoll_createepoll_ctlepoll_wait 三个主要接口,操作更简洁高效。

4. 触发模式

select 的缺点:

水平触发:select 只能支持水平触发,即当文件描述符有数据时,select 会一直返回该文件描述符,直到数据被处理完。

epoll 的优势:

边缘触发:epoll 支持边缘触发和水平触发。边缘触发模式下,只有当状态发生变化时才会通知,大大减少了无效的系统调用次数,提高了效率。

具体代码示例

select 示例:
c++
#include <sys/select.h>
#include <unistd.h>
#include <stdio.h>

int main() {
    fd_set readfds;
    FD_ZERO(&readfds);
    FD_SET(STDIN_FILENO, &readfds);

    struct timeval tv;
    tv.tv_sec = 5;
    tv.tv_usec = 0;

    int retval = select(STDIN_FILENO + 1, &readfds, NULL, NULL, &tv);
    if (retval == -1) {
        perror("select()");
    } else if (retval) {
        printf("Data is available now.\n");
    } else {
        printf("No data within five seconds.\n");
    }

    return 0;
}
epoll 示例:
c++
#include <sys/epoll.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    int epfd = epoll_create(1);
    if (epfd == -1) {
        perror("epoll_create");
        exit(EXIT_FAILURE);
    }

    struct epoll_event ev, events[10];
    ev.events = EPOLLIN;
    ev.data.fd = STDIN_FILENO;

    if (epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev) == -1) {
        perror("epoll_ctl: stdin");
        exit(EXIT_FAILURE);
    }

    int nfds = epoll_wait(epfd, events, 10, -1);
    if (nfds == -1) {
        perror("epoll_wait");
        exit(EXIT_FAILURE);
    }

    for (int n = 0; n < nfds; ++n) {
        if (events[n].data.fd == STDIN_FILENO) {
            printf("stdin is readable\n");
        }
    }

    close(epfd);
    return 0;
}

6、ET和LT的区别?

1. 水平触发(Level Triggered,LT)

这是 epoll 的默认工作模式。LT 模式下,当文件描述符就绪时,只要应用程序没有处理该事件,epoll_wait 就会不断返回该文件描述符的事件。

特点:

  • 重复通知:只要文件描述符上的事件没有被处理,epoll_wait 会持续返回。
  • 简单易用:LT 模式类似于传统的 selectpoll,容易理解和使用。

示例:

假设某个文件描述符上有数据可读,只要数据没有被完全读取,epoll_wait 每次都会返回该文件描述符。

c++
struct epoll_event ev, events[MAX_EVENTS];
int epfd = epoll_create(1);

ev.events = EPOLLIN;  // 水平触发
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; ++i) {
        if (events[i].events & EPOLLIN) {
            // 每次都有数据未读完,epoll_wait 都会返回该文件描述符
            read(events[i].data.fd, buffer, sizeof(buffer));
        }
    }
}

2. 边缘触发(Edge Triggered,ET)

ET 模式下,只有当文件描述符的状态发生变化时,epoll_wait 才会返回该文件描述符。也就是说,事件只会触发一次,除非你再次对文件描述符进行操作,使其状态发生变化。

特点:

  • 一次性通知:当文件描述符上有事件发生时,epoll_wait 只会通知一次,直到你处理完该事件。
  • 高效:ET 模式减少了系统调用次数,适合处理高速 I/O,但需要程序员更小心地处理。

示例:

假设某个文件描述符上有数据可读,只有当数据状态发生变化时(比如新数据到达),epoll_wait 才会再次返回该文件描述符。

c++
struct epoll_event ev, events[MAX_EVENTS];
int epfd = epoll_create(1);

ev.events = EPOLLIN | EPOLLET;  // 边缘触发
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

while (1) {
    int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < nfds; ++i) {
        if (events[i].events & EPOLLIN) {
            while (1) {
                ssize_t count = read(events[i].data.fd, buffer, sizeof(buffer));
                if (count == -1) {
                    if (errno == EAGAIN) {
                        // 已经读完所有数据
                        break;
                    } else {
                        // 处理读取错误
                        perror("read");
                        break;
                    }
                } else if (count == 0) {
                    // 处理 EOF
                    close(events[i].data.fd);
                    break;
                }
                // 处理读取到的数据
            }
        }
    }
}

7、另一端断开连接了,本端如何知道它断开了?

  1. 套接字关闭返回值:当另一端关闭连接时,在读取数据时,套接字函数会返回一个值,告诉你连接已经关闭。在使用 recv 函数时,如果返回 0,表示连接已关闭。
  2. 错误处理:在发送或接收数据时,如果发生错误,可以通过检查 errno 来确定是否是因为连接断开而引发的错误。常见的错误码包括 ECONNRESET(连接被对方重置)和 ENOTCONN(连接已经关闭)等。
  3. 心跳检测:通过定期向对端发送心跳包,并等待对端的回复,如果一定时间内没有收到回复,则可以认为对端已断开连接。
  4. 使用信号:在某些情况下,操作系统会发送 SIGPIPE 信号给进程,告知连接已断开。可以通过捕获该信号来处理连接断开的情况。

8、快排是否稳定,有哪些是稳定的?