跳转至

operating system

Thread

导言

通过多线程来利用多核是常见的加速方法,但是随着代码的开发,运行时可能有30~40个子线程共存,但是这些线程的重要程度往往是不同的,OS默认调度是感知不到线程的重要性,

因此需要:

  1. 使用线程优先级来提高线程的重要性。
  2. 通过主动绑核来隔离各个线程。

基础知识

切换开销

线程在核间切换的开销是极小的(Java uses lots of threads but threads have become significantly faster and cheaper with the NPTL in Linux 2.6.),与其考虑切换开销,不如注意不同优先级线程同一个核竞争的问题。

静态/动态优先级 与 调度域

  • 基本概念:
  • 静态/动态优先级:见 深入理解Linux内核 - 第七章 进程调度 - 调度算法。
  • 调度域:见 深入理解Linux内核 - 第七章 进程调度 - 多处理器系统中任务队列的平衡 - 调度域1
物理机调度会感知到超线程,NUMA结构,并避免在之间调度

创建

在C++中,有几种方式可以实现线程的创建。下面是一些常见的方法:

1. 使用 C++11 标准库的 std::thread

这是C++11标准引入的线程库,使用起来非常方便。你可以直接创建一个 std::thread 对象并传递一个函数或可调用对象(如lambda表达式)。

示例代码:

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t(threadFunction);  // 创建线程,立刻执行 threadFunction
    t.join();  // 等待线程执行完毕
    return 0;
}

std::thread t(threadFunction); 子线程会立刻执行

使用 std::thread 创建的子线程会立刻开始执行。具体来说,当你创建 std::thread 对象并将函数或可调用对象传递给它时,线程会立即开始执行你传递的函数或代码块。

例如,以下代码中的子线程会在创建后立即执行 threadFunction

#include <iostream>
#include <thread>

void threadFunction() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t(threadFunction);  // 创建线程,子线程立刻开始执行
    t.join();  // 等待子线程执行完毕
    return 0;
}

在这段代码中,一旦执行到 std::thread t(threadFunction); 这行代码,子线程会立刻开始执行 threadFunction。主线程则继续执行后面的代码。在调用 t.join(); 之前,主线程和子线程是并行执行的。

需要注意的是,如果不调用 t.join();,主线程可能会在子线程完成之前就结束,从而导致子线程的输出可能不会被看到。因此,通常会使用 join() 来确保主线程等待子线程执行完毕。

2. 使用 std::asyncstd::future

std::async 可以用于异步执行任务,返回一个 std::future 对象,你可以通过这个对象获取任务的执行结果。

示例代码:

#include <iostream>
#include <future>

int asyncFunction() {
    return 42;
}

int main() {
    std::future<int> result = std::async(asyncFunction);  // 异步执行函数
    std::cout << "Result: " << result.get() << std::endl;  // 获取结果
    return 0;
}

3. 使用 POSIX 线程(Pthreads)

在使用较早的C++版本或在一些特定的操作系统(如Linux)下,pthread 是创建和管理线程的常用方式。需要包含 <pthread.h> 头文件。

示例代码:

#include <iostream>
#include <pthread.h>

void* threadFunction(void*) {
    std::cout << "Hello from POSIX thread!" << std::endl;
    return nullptr;
}

int main() {
    pthread_t thread;
    pthread_create(&thread, nullptr, threadFunction, nullptr);  // 创建POSIX线程
    pthread_join(thread, nullptr);  // 等待线程结束
    return 0;
}

4. 使用Boost库

Boost库提供了丰富的线程管理功能,使用起来与标准库类似,但需要安装Boost库。

示例代码:

#include <boost/thread.hpp>
#include <iostream>

void threadFunction() {
    std::cout << "Hello from Boost thread!" << std::endl;
}

int main() {
    boost::thread t(threadFunction);  // 创建Boost线程
    t.join();  // 等待线程执行完毕
    return 0;
}

5. 使用原生操作系统API

可以直接调用操作系统提供的API来创建线程,例如在Windows上使用 CreateThread,在Unix/Linux上使用 pthread_create

Windows上的示例代码:

#include <windows.h>
#include <iostream>

DWORD WINAPI threadFunction(LPVOID lpParam) {
    std::cout << "Hello from Windows thread!" << std::endl;
    return 0;
}

int main() {
    HANDLE thread = CreateThread(
        nullptr,         // 默认安全属性
        0,               // 默认堆栈大小
        threadFunction,  // 线程函数
        nullptr,         // 线程函数参数
        0,               // 默认创建标志
        nullptr);        // 返回线程ID

    WaitForSingleObject(thread, INFINITE);  // 等待线程结束
    CloseHandle(thread);  // 关闭线程句柄
    return 0;
}

总结

  • C++11 std::thread 是最常用和推荐的方式,简单易用且跨平台。
  • std::async 更适合用于需要返回值的异步操作。
  • POSIX pthread 适合在类Unix系统上使用,适应性强但需要更多的低层管理。
  • Boost线程库 提供了丰富的功能,适合需要额外功能的场合。
  • 操作系统API 更适合需要与操作系统深度集成的场景。

修改

线程独占的信息很少,一般就是线程名的获取和设置。

线程名

#include <sys/prctl.h>

if (prctl(PR_SET_NAME, ("ACL_thread")) != 0) {
    ASCEND_LOGE("set thread name failed!");
}

char thread_name[16];  // 线程名称最大为16字节,包括终止符
if (prctl(PR_GET_NAME, thread_name, 0, 0, 0) == 0) {
    std::cout << "Thread name: " << thread_name << std::endl;
} else {
    std::cerr << "Error getting thread name" << std::endl;
}

处理器亲和性

如何结合Host端和Device端的设计细节来高效线程调度

  • nvidia-smi等知道busID,
  • 使用类似 lspci -vv |grep "Processing a" -A 6 知道NUMA拓扑
  • numactl -H 看内存分布

鲲鹏920的环形总线结构

192核,分为8个node,每个node的24核又分为6个cluster,每个cluster的4个核为最小单元。

  • 简单测试: 使用taskset -c 0-4 {command},可以实现命令绑定在编号0-3核上。
  • 代码内实现: c++内通过pthread_setaffinity_np函数实现。
    • 绑定到NUMA node后,利用OS的自动CPU负载均衡即可。
将线程A绑定到一个核心上后,从这个线程创建出的子线程应该是会继承这个pthread_setaffinity_np的效果, 还有Thread name
  • htop -p pida 选项可以显示已有的亲和性设置
  • ps -p pid 可以看见CMD相同。

CPU负载均衡:线程的自动切换

线程在不同的 CPU 核之间切换是一种常见现象,这种行为被称为 CPU 负载均衡。它并不总是为了让线程变快,而是为了更好地管理系统资源和提高整体系统性能。以下是为什么会发生这种切换的原因:

  1. 负载均衡
  2. 操作系统调度器 会尝试将 CPU 负载分布在所有可用的核心上,以防止某些核心过载或某些核心闲置。这种均衡有助于提高系统的整体性能和响应速度。
  3. 当一个核心的负载变得很高时,调度器可能会将一些线程移到其他较空闲的核心上执行,以平衡系统负载。

  4. 热管理

  5. 多核处理器通常具有动态电源管理和温度控制机制。如果某个核心由于过度使用而变热,调度器可能会将线程切换到另一个较冷的核心,以避免过热并确保处理器稳定运行。

  6. 中断处理

  7. 操作系统可能会将某些中断处理程序绑定到特定的核心上。当这些中断发生时,当前正在处理的线程可能会被暂停,并移到另一个核心继续执行。

  8. 资源竞争

  9. 不同的核心共享某些资源(例如缓存、内存控制器等)。如果多个线程在同一个核心上运行,可能会导致资源争用。调度器可以通过在不同核心之间移动线程来减轻这种争用,从而提高性能。

  10. 调度策略

  11. Linux 内核使用的调度策略(如 CFS:Completely Fair Scheduler)可能会认为在不同核心之间移动线程可以更公平地分配 CPU 时间片,从而提高系统的整体吞吐量。

是否会变快?线程在不同核心之间的切换本身不会让某个线程“变快”。相反,频繁的切换会引入一些开销(如缓存失效,线程状态切换等),在某些情况下甚至可能导致性能下降。

然而,通过均衡负载、管理热量和资源争用,操作系统调度器可以确保系统的稳定性和长时间运行时的性能一致性。因此,从全局来看,这种行为通常有利于提高系统的整体性能和响应能力,而不仅仅是单个线程的性能。

解决方法:如果你希望某个线程固定在一个核心上运行(避免在不同核心之间切换),可以使用 CPU 亲和性 来绑定线程到特定核心。但在大多数情况下,操作系统调度器会比手动绑定做得更好,除非你有非常特殊的性能要求。

使用 isolcpus 的内核设置可以隔离核心,但是这需要重启,有什么办法能不重启实现隔离吗

你可以使用 cset 工具来动态隔离 CPU 核心,而无需重启系统。csetcpuset 的前端工具)允许你创建 CPU 和内存的分组,并将特定的任务分配到这些分组中,从而达到核心隔离的效果。以下是如何使用 cset 实现隔离的基本步骤:

  1. 安装 cset:

如果你的系统上还没有安装 cset,你可以使用包管理工具进行安装,例如在 Ubuntu 上:

sudo apt-get install cset
  1. 创建 CPU 集(cpuset):

使用 cset 创建一个新的 CPU 集,比如将 CPU 2 和 3 隔离:

sudo cset set --cpu=2,3 --set=isolated
  1. 将任务分配到隔离的 CPU 集:

你可以将一个特定的任务(如进程 ID 或命令)绑定到刚才创建的 CPU 集中:

sudo cset proc --set=isolated --exec command

或者,如果你有一个运行中的进程,假设它的 PID 是 1234:

sudo cset proc --set=isolated --move 1234
  1. 查看当前 CPU 集:

你可以通过以下命令查看所有 CPU 集和任务的分配情况:

sudo cset set --list
  1. 移除 CPU 集:

如果你想恢复 CPU 核心的默认状态,可以删除创建的 CPU 集:

sudo cset set --destroy isolated

通过使用 cset,你可以在不重启系统的情况下灵活地管理 CPU 核心的分配和隔离。

请注意,cset 适用于需要较高实时性和性能隔离的应用场景。如果你的系统任务调度特别复杂,使用 cset 进行动态隔离可能需要更多的调试和配置。

数据竞争

进程内的多个线程共享地址空间,意味着共享全局变量,需要使用锁来避免写冲突

  • 线程不拥有系统资源,但它可以与同属一个进程的其他线程共享进程的资源。
  • 线程有自己的程序计数器、寄存器集合和栈。
使用互斥锁访问全局变量
  1. 共享访问
  2. 全局变量可以被进程中的所有线程访问和修改。
  3. 数据竞争
  4. 当多个线程同时访问和修改同一个全局变量时,可能会发生数据竞争(race condition),导致不可预测的结果。
  5. 线程安全
  6. 如果全局变量被多个线程访问,需要确保对这些变量的访问是线程安全的。这通常通过使用互斥锁(mutexes)、读写锁(read-write locks)、原子操作(atomic operations)或其他同步机制来实现。
  7. 可见性
  8. 在某些情况下,一个线程对全局变量的修改可能不会立即对其他线程可见。这与处理器缓存、编译器优化以及内存屏障(memory barriers)的使用有关。
  9. 初始化
  10. 全局变量的初始化在多线程环境中需要特别注意,以确保在任何线程访问变量之前,变量已经被正确初始化。

为了确保线程安全,可以使用互斥锁:

#include <iostream>
#include <thread>
#include <vector>
#include <mutex>

int globalVariable = 0;
std::mutex globalMutex; // 互斥锁

void incrementGlobal() {
    std::lock_guard<std::mutex> lock(globalMutex); // 锁定互斥锁
    globalVariable++;
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.push_back(std::thread(incrementGlobal));
    }

    for (auto& thread : threads) {
        thread.join();
    }

    std::cout << "Final value of globalVariable: " << globalVariable << std::endl;
    return 0;
}

在这个同步版本中,我们使用 std::mutexstd::lock_guard 来确保每次只有一个线程可以修改全局变量,从而避免了数据竞争。

总之,虽然线程可以访问同一个全局变量,但必须小心处理并发访问,以确保程序的正确性和稳定性。

单例模式,全局(互斥锁)来监控,所有线程

要实现一个全局的 class 来统计进程中所有出现的线程的 TID,并为每个 TID 分配一个 CPU 核心,可以按照以下步骤进行设计。

  • 提供注册线程和分配核心的功能。
  • 提供一个静态实例,以确保全局唯一性。
#include <iostream>
#include <map>
#include <thread>
#include <mutex>
#include <sys/syscall.h>
#include <unistd.h>

class ThreadManager {
public:
    // 获取ThreadManager实例
    static ThreadManager& getInstance() {
        static ThreadManager instance;
        return instance;
    }

    // 注册线程并分配核心
    void registerThread() {
        std::lock_guard<std::mutex> lock(mutex_);
        pid_t tid = syscall(SYS_gettid);  // 获取当前线程的TID
        if (threadMap_.find(tid) == threadMap_.end()) {
            int core = getNextCore();
            threadMap_[tid] = core;
            std::cout << "Thread " << tid << " assigned to core " << core << std::endl;
        }
    }

    // 获取分配给特定TID的核心
    int getCore(pid_t tid) {
        std::lock_guard<std::mutex> lock(mutex_);
        auto it = threadMap_.find(tid);
        if (it != threadMap_.end()) {
            return it->second;
        }
        return -1; // 如果未找到TID,则返回-1
    }

private:
    std::map<pid_t, int> threadMap_;  // 线程ID和核心的映射表
    std::mutex mutex_;                // 保护threadMap_的互斥锁
    int nextCore_;                    // 下一个分配的核心号

    ThreadManager() : nextCore_(0) {}  // 构造函数初始化nextCore_

    // 获取下一个核心号(简单轮询策略)
    int getNextCore() {
        int core = nextCore_;
        nextCore_ = (nextCore_ + 1) % std::thread::hardware_concurrency();  // 轮询分配核心
        return core;
    }

    // 禁止复制构造和赋值操作符
    ThreadManager(const ThreadManager&) = delete;
    ThreadManager& operator=(const ThreadManager&) = delete;
};

// 测试线程函数
void threadFunction() {
    ThreadManager::getInstance().registerThread();  // 注册线程
    // 线程的其他操作
}

int main() {
    std::thread t1(threadFunction);
    std::thread t2(threadFunction);

    t1.join();
    t2.join();

    return 0;
}
  1. ThreadManager 类:

    • 采用了单例模式来确保全局唯一性。
    • registerThread() 函数用于在 threadMap_ 中注册线程的 TID 并分配核心号。
    • getCore() 函数用于获取特定 TID 的分配核心。
  2. 线程注册:

    • 在每个线程中调用 ThreadManager::getInstance().registerThread() 来注册线程的 TID 并分配核心。

待研究

  1. pthread to learn
  2. 父子线程进程的退出影响

参考文献

File System

文件系统简介

计算机文件系统是操作系统的一个重要组成部分,它管理计算机存储设备上的文件,负责文件存储、读取和组织等功能。文件系统的主要作用包括:

  • 文件存储与寻址:文件系统负责在存储设备上对文件进行存取,需要找到文件在存储设备上的位置。常见的寻址机制有:
  • FAT表:使用文件分配表记录每个文件所占用簇的位置。
  • inode:为每个文件分配一个inode,记录文件存储位置、大小、访问时间等元信息。
  • 文件组织与优化:文件系统负责组织硬盘空间,常见的组织结构包括:
  • 目录结构:将文件组织成目录/子目录的树形层次结构。
  • 碎片整理:通过碎片整理优化空间利用率。
  • 块大小:通过调整块大小来改善IO性能。
  • 访问控制:管理文件访问权限、用户组等信息,确保访问安全。
  • 高级功能:一些文件系统实现了高级功能,如快照、数据压缩、加密等。
  • 系统完整性:提供一致性检验、崩溃恢复机制来保证文件系统完整可靠。

常见的文件系统包括Windows上的FAT、NTFS,Unix/Linux上的ext、XFS、Btrfs等。

文件系统的设计对操作系统的性能、安全性有很大影响。一个优秀的文件系统应提供高效的IO访问、良好的安全控制和数据完整性保障。选择正确的文件系统对不同场景也很重要。

评估文件系统

  • 性能:读写速度、响应时间、并发支持如何,可以测试IO性能。
  • 可靠性:数据完整性保证、Crash可恢复性如何,测试崩溃恢复。
  • 安全性:访问控制、防篡改机制如何,测重写、破坏后的数据恢复。
  • 容量:最大文件大小、卷大小、目录容量如何,测试边界极限。
  • 扩展性:可线性扩展还是需要重构,测试大容量情况下的性能。
  • 元数据:元信息组织结构,是否支持快速查找、高级索引。
  • 分配机制:如何分配和回收空间,是否会产生碎片。
  • 一致性:是否保证读写顺序一致性,如何支持缓存与本地IO。
  • 插件机制:是否可以通过插件扩展功能,如压缩、加密等。
  • 兼容性:是否兼容主流平台和老系统,测试迁移和交互兼容性。

基本概念:数据分块存储

文件储存在硬盘上,硬盘的最小存储单位叫做"扇区"(Sector)。每个扇区储存512字节(相当于0.5KB)。

但是就像内存读取也不会只读一个字节, 硬盘的存储和读取都是按照Block进行的(比如,4KB即连续八个 sector组成一个 block。)

早期:FAT文件系统

早期文件分配表(File Allocate Table,FAT) 链表结构解决了文件和物理块映射的问题。

小结

  • 常见的FAT12、FAT16、FAT32格式,用于早期Windows系统。
  • 优点:简单易用,支持跨平台
  • 缺点:非常占用内存, 效率和安全性不高。
  • 比如 1T 的硬盘,如果块的大小是 1K,那么就需要 1G 个 FAT 条目。
  • 通常每个 FAT 条目还会存一些其他信息,需要 2~3 个字节, FAT条目总共占用 2-3G 的内存空间,才能用来管理 1T 的硬盘空间。

常见:基于inode的文件系统

基于 inode(index node的数据结构) 的传统文件系统。文件数据被存储不同块里面,文件的元数据信息就会被存储在inode里面。

特点

  • 在Unix/Linux中广泛使用的文件系统,如Ext、XFS等。
  • 每个文件都有一个对应的inode,记录文件元信息和数据块位置。
  • 操作系统通过inode找到文件内容,支持权限控制等高级功能。
  • 效率高,安全可靠,但inode会占用一定存储空间。

文件操作流程

由于inode号码与文件名分离

  • 删除流程
  • 删除一个文件名,就会使得inode节点中的"链接数"减1。当这个值减到0,表明没有文件名指向这个inode,系统就会回收这个inode号码,以及其所对应block区域。
  • 直接删除inode,能够起到删除(包含特殊字符)文件的作用find ./* -inum 节点号 -delete
  • 移动文件或重命名文件
  • 只是改变文件名,不影响inode号码;

inode存储Block信息

为了解决数据变化问题,它引入了3个存储指针设计

  • 直接指针可以直接指向数据块本身,数据块就是保存数据的块
  • 间接指针是在前面的指针指针不够的时候才会启用,间接指针可以看成链表那样,间接指针会指向一个个索引块,这块本身又是一个数据块的指针也是只是指向存储数据块。
  • 第3类指针,指向一个二级索引块,二级索引块的指针还可以指向新的索引块

大小占用

  • 每个inode节点的大小固定,一般是128字节或256字节。
  • inode节点的总数,在格式化时就给定,一般是每1KB或每2KB就设置一个inode
  • 每个文件都必须有一个inode,因此有可能发生inode已经用光,但是硬盘还未存满的情况。这时,就无法在硬盘上创建新文件。
  • 每个inode都有一个号码,操作系统用inode号码来识别不同的文件。
$ df -hi .
Filesystem     Inodes IUsed IFree IUse% Mounted on
/dev/sda2         56M  5.1M   51M   10% /
或者
/dev/sda2      58605568 5321132 53284436   10% /

# 每个inode节点的大小,一般是128字节或256字节。
$ sudo dumpe2fs -h /dev/sda2 | grep "Inode size"
dumpe2fs 1.45.5 (07-Jan-2020)
Inode size:               256

58605568/256 = 222928 个 inode节点

$ fdisk -l
Disk /dev/sda: 894.26 GiB, 960197124096 bytes, 1875385008 sectors
Disk model: INTEL SSDSC2KB96
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
Disklabel type: gpt
Disk identifier: 86F8050E-C9E7-4BDB-8B0C-89E20B013FF6

Device     Start        End    Sectors   Size Type
/dev/sda1   2048       4095       2048     1M BIOS boot
/dev/sda2   4096 1875382271 1875378176 894.3G Linux filesystem

$ sudo fdisk -l /dev/sda2
Disk /dev/sda2: 894.26 GiB, 960193626112 bytes, 1875378176 sectors (512 bytes per sector)
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes

1875378176/228928 = 8192(8K) 个 sector 对应一个inode节点

一个inode节点对应 4MB的空间?

目录、软链接、硬链接

  • 目录:目录是一种特殊的文件,它的inode节点中存储的是文件名和inode号码的对应关系。
  • 软链接:软链接拥有自己的inode,但是文件内容就是一个快捷方式。
  • 命令 ln -s /etc/nginx/config link_config
  • 文件A和文件B的inode号码虽然不一样,但是文件A的内容是文件B的路径。读取文件A时,系统会自动将访问者导向文件B。因此,无论打开哪一个文件,最终读取的都是文件B。这时,文件A就称为文件B的"软链接"(soft link)或者"符号链接(symbolic link)。
  • 文件A指向文件B的文件名,而不是文件B的inode号码,文件B的inode"链接数"不会因此发生变化。
  • 硬链接:多个文件名指向同一个inode号码。
  • 命令 ln main.c link_main.c

软链接、硬链接区别与使用场景

在大部分常见场景下,硬链接是更优的选择:

  1. 硬链接是一个真实文件,不会无效,更可靠。软链接指向的文件移动或删除后会失效。
  2. 硬链接与原文件性能一致,软链接需要在查询时重新解析路径。

硬链接也有一些限制:

  1. 不能跨文件系统,软链接可以实现跨文件系统的链接。
  2. 目录无法创建硬链接,只能软链接。

日志文件系统

  • NTFS 和 Ext3 是日志文件系统,它们和 FAT 最大的区别在于写入到磁盘中的是日志,而不是数据。
  • 日志文件系统会先把日志写入到内存中一个高速缓冲区,定期写入到磁盘。
  • 日志写入是追加式的,不用考虑数据的覆盖。
  • 一段时间内的日志内容,会形成还原点。这种设计大大提高了性能,当然也会有一定的数据冗余。

日志文件系统 与 inode 文件系统的关系

  1. 日志文件系统是一种技术,可以建立在多种文件系统之上,包括inode文件系统。
  2. inode文件系统是一种文件系统架构,每个文件都有一个inode保存元信息。ext、xfs等都是这种架构。
  3. 但两者不是必然关联的,日志文件系统技术也可以用在非inode型文件系统上。

常见名词及文件系统:MBR、GPT和FAT、EXT2

前两者是磁盘分区格式,后两者是文件系统格式。

MBR、GPT是两种比较常见的磁盘分区格式,而且对于磁盘分区而言,目前也主要是这两种格式。一个分区是一个存储设备上的一块独立区域,用户可以针对这块区域进行单独管理。

  • NFS:网络文件系统,允许通过网络访问文件,可应用在分布式系统。
  • ZFS:引入了pooled storage和checksum等特性的128位文件系统。
  • HFS:Mac OS使用的层次化文件系统,使用B*树对元数据进行组织。

实践:fdisk的结果

  • “Disk label type”表示当前磁盘的分区形式,
  • dos表示磁盘分区形式为MBR,
  • gpt表示磁盘分区形式为GPT

Windows NTFS文件系统

New Technology File System (NTFS):Windows NT引入的文件系统,使用主文件表(MFT)来管理文件,支持高级功能如权限控制等。

  • NTFS卷上的任何事物都是文件(为了与平时使用的文件相区别,以下用FILE特指),
  • FILE通过主文件表(master file table, MFT)来确定其在卷上的位置,
  • 每个FILE有固定大小,一般为1KB。
  • FILE记录了文件的所有数据,每个数据以一个属性来表示,如文件名、文件长度、文件的时间等都是属性,文件的内容也是一个属性,每个属性有一个特征码。
  • 属性数据较小时能够存放在FILE记录中,称为驻留的属性,反之为非驻留的属性,通过Data Runs来保存其存储索引表。
  • 这一点与FAT文件系统不同,FAT文件系统只在目录区保存了文件的首簇号,还要通过FAT表链接关系才能确定文件的全部存放位置。
  • Data Runs在一个FILE记录存放不下时还可以用扩展属性,增加FILE记录来保存,即一个文件可以有多个FILE记录。

NTFS的同层目录采用B+树结构,按文件(夹)名保持有序,通过文件号指向文件夹内的文件。文件夹的目录项较少时可以直接存储在文件夹的FILE记录中,目录项较多时占用数据簇,建立INDX记录,存放各目录项的属性。

NTFS文件系统一共由16个“元文件”构成

Linux常见 EXT文件系统的发展简介

ext1

  • 优点:1992 年的 ext 使用在 Linux 内核中的新虚拟文件系统(VFS)抽象层。
  • 与之前的 MINIX 文件系统不同的是,ext 可以处理高达 2 GB 存储空间并处理 255 个字符的文件名。
  • 缺点:原始的时间戳(每个文件仅有一个时间戳,而不是今天我们所熟悉的有 inode、最近文件访问时间和最新文件修改时间的时间戳。)

ext2

  • 优点:提供了 GB 级别的最大文件大小和 TB 级别的文件系统大小。
  • 缺点:
  • 将数据写入到磁盘的时候,系统发生崩溃或断电,则容易发生灾难性的数据损坏。
  • 随着时间的推移,由于碎片(单个文件存储在多个位置,物理上其分散在旋转的磁盘上),它们也遭受了严重的性能损失。

ext3

  • 2001 年 11 月在 2.4.15 内核版本中被采用到 Linux 内核主线中。
  • 优点:使用日志来解决断电数据不一致问题,和 20 世纪 90 年代后期的其它文件系统一样,如微软的 NTFS。

ext4

  • 2008年在 2.6.28 内核版本中被加入到了 Linux 主线。
  • 优点:
  • 支持大文件系统,
    • ext3 文件系统使用 32 位寻址,这限制它仅支持 2 TiB 文件大小和 16 TiB 文件系统系统大小
    • ext4 使用 48 位的内部寻址,理论上可以在文件系统上分配高达 16 TiB 大小的文件,其中文件系统大小最高可达 1000000 TiB(1 EiB)
  • 分配方式改进,显著提高读写性能
    • 区段(extent)
    • 是一系列连续的物理块 (最多达 128 MiB,假设块大小为 4 KiB),可以一次性保留和寻址。使用区段而不是 block可以减少给定文件所需的 inode 数量,并显著减少碎片并提高写入大文件时的性能。
    • 多块分配(multiple block allocation)
    • ext3 为每一个新分配的块调用一次块分配器。当多个写入同时打开分配器时,很容易导致严重的碎片。
    • 多块分配(multiple block allocation)允许一次性分配大量的连续文件块,以降低碎片并且有利于 RAID 设备的并行写入
    • 延迟分配(delayed block allocation)
    • ext4 使用延迟分配(delayed block allocation),这允许它合并写入并更好地决定如何为尚未提交的写入分配块。
    • 延迟分配允许 ext4 等待分配将写入数据的实际块,直到它准备好将数据提交到磁盘。(相比之下,即使数据仍然在往写入缓存中写入,ext3 也会立即分配块。)
    • 当缓存中的数据累积时,延迟分配块允许文件系统对如何分配块做出更好的选择,降低碎片(写入,以及稍后的读)并显著提升性能。
    • 持久的预分配( allocation without initialization)
    • 在为文件预分配磁盘空间时,大部分文件系统必须在创建时将零写入该文件的块中。
    • ext4 允许替代使用 fallocate(),它保证了空间的可用性(并试图为它找到连续的空间),而不需要先写入它。这显著提高了写入和将来读取流和数据库应用程序的写入数据的性能。
  • 提高了对碎片的抵抗力
    • ext2 和 ext3 都不直接支持在线碎片整理 —— 即在挂载时会对文件系统进行碎片整理。
    • ext4 通过 e4defrag 解决了这个问题,且是一个在线、内核模式、文件系统感知、块和区段级别的碎片整理实用程序。

Nas 防止碎片,开启预分配

实践问题: Nas 的 ext4 挂载被识别成NTFS

估计是有一层转换,类似的软件有 UFS Explorer

磁盘读写原理

读写操作分层

对于磁盘的一次读请求,

  • 首先经过虚拟文件系统层(VFS Layer),
  • 其次是具体的文件系统层(例如Ext2),
  • 接下来是Cache层(Page Cache Layer)、
  • 通用块层(Generic Block Layer)、
  • I/O调度层(I/O Scheduler Layer)、
  • 块设备驱动层(Block Device Driver Layer),
  • 最后是物理块设备层(Block Device Layer)。
Page Cache层

  • 为了提高Linux操作系统对磁盘访问的性能。Cache层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在Cache中存在该数据且是最新的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。
  • 文件Cache分为两个层面,
  • 一是Page Cache,另一个Buffer Cache,每一个Page Cache包含若干Buffer Cache。
  • Page Cache主要用来作为文件系统上的文件数据的缓存来用,尤其是针对当进程对文件有read/write操作的时候。
  • Buffer Cache则主要是设计用来在系统对块设备进行读写的时候,对块进行数据缓存的系统来使用。
  • 磁盘Cache有两大功能:预读和回写。
  • 预读其实就是利用了局部性原理,具体过程是:
    • 对于每个文件的第一个读请求,系统读入所请求的页面并读入紧随其后的少数几个页面(通常是三个页面),这时的预读称为同步预读。
    • 对于第二次读请求,
    • 如果所读页面不在Cache中,即不在前次预读的页中,则表明文件访问不是顺序访问,系统继续采用同步预读;
    • 如果所读页面在Cache中,则表明前次预读命中,操作系统把预读页的大小扩大一倍,此时预读过程是异步的,应用程序可以不等预读完成即可返回,只要后台慢慢读页面即可,这时的预读称为异步预读。
    • 任何接下来的读请求都会处于两种情况之一:第一种情况是所请求的页面处于预读的页面中,这时继续进行异步预读;第二种情况是所请求的页面处于预读页面之外,这时系统就要进行同步预读。
  • 回写是通过暂时将数据存在Cache里,然后统一异步写到磁盘中。
    • 通过这种异步的数据I/O模式解决了程序中的计算速度和数据存储速度不匹配的鸿沟,减少了访问底层存储介质的次数,使存储系统的性能大大提高。Linux
    • 2.6.32内核之前,采用pdflush机制来将脏页真正写到磁盘中,什么时候开始回写呢?下面两种情况下,脏页会被写回到磁盘:
    • 在空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。
    • 当脏页在内存中驻留超过一定的阈值时,内核必须将超时的脏页写会磁盘,以确保脏页不会无限期地驻留在内存中。

I/O调度层

  • I/O调度层的功能是管理块设备的请求队列。
  • 即接收通用块层发出的I/O请求,缓存请求并试图合并相邻的请求。
  • 并根据设置好的调度算法,回调驱动层提供的请求处理函数,以处理具体的I/O请求。
  • 如果简单地以内核产生请求的次序直接将请求发给块设备的话,那么块设备性能肯定让人难以接受,因为磁盘寻址是整个计算机中最慢的操作之一。
  • 为了优化寻址操作,内核不会一旦接收到I/O请求后,就按照请求的次序发起块I/O请求。
  • 为此Linux实现了几种I/O调度算法,算法基本思想就是通过合并和排序I/O请求队列中的请求,以此大大降低所需的磁盘寻道时间,从而提高整体I/O性能。

常见的I/O调度算法包括

  1. Noop调度算法(No Operation)、
  2. CFQ(完全公正排队I/O调度算法)、
  3. DeadLine(截止时间调度算法)、
  4. AS预测调度算法等。

磁盘快速I/O常见机制

Linux系统中请求到达磁盘的一次完整过程,期间Linux一般会通过Cache以及排序合并I/O请求来提高系统的性能。

其本质就是由于磁盘随机读写慢、顺序读写快的磁盘I/O特性。

采用追加写

在进行系统设计时,良好的读性能和写性能往往不可兼得。在许多常见的开源系统中都是优先在保证写性能的前提下来优化读性能。那么如何设计能让一个系统拥有良好的写性能呢?

  • 一个好的办法就是采用追加写,每次将数据添加到文件。
  • 由于完全是顺序的,所以可以具有非常好的写操作性能。
  • 但是这种方式也存在一些缺点:从文件中读一些数据时将会需要更多的时间:
  • 需要倒序扫描,直到找到所需要的内容。
  • 当然在一些简单的场景下也能够保证读操作的性能:
    • 数据是被整体访问,比如HDFS
    • 知道文件明确的偏移量,比如Kafka
  • 在面对更复杂的读场景(比如按key)时,如何来保证读操作的性能呢?
    • 简单的方式是像Kafka那样,将文件数据有序保存,使用二分查找来优化效率;
    • 或者通过建索引的方式来进行优化;
    • 也可以采用hash的方式将数据分割为不同的桶。
  • 以上的方法都能增加读操作的性能,但是由于在数据上强加了数据结构,又会降低写操作的性能。
    • 比如如果采用索引的方式来优化读操作,那么在更新索引时就需要更新B-tree中的特定部分,这时候的写操作就是随机写。
  • 那么有没有一种办法在保证写性能不损失的同时也提供较好的读性能呢?
  • 一个好的选择就是使用LSM-tree。
    • LSM-tree与B-tree相比,LSM-tree牺牲了部分读操作,以此大幅提高写性能。

文件合并和元数据优化

目前的大多数文件系统,如XFS/Ext4、GFS、HDFS,在元数据管理、缓存管理等实现策略上都侧重大文件。

磁盘碎片

不同文件系统的比较

像 FAT 和 FAT32 这类文件系统中,文件紧挨着写入到磁盘中。文件之间没有空间来用于增长或者更新:

NTFS 中在文件之间保留了一些空间,因此有空间进行增长。但因块之间的空间是有限的,碎片也会随着时间出现。

Linux 的日志型文件系统采用了一个不同的方案。与文件相互挨着不同,每个文件分布在磁盘的各处,每个文件之间留下了大量的剩余空间。这就给文件更新和增长留下了很大的空间,碎片很少会发生。

此外,碎片一旦出现了,大多数 Linux 文件系统会尝试将文件和块重新连续起来。

检测命令

在已经挂载的分区中运行 fsck 将会严重危害到你的数据和磁盘。

umount /path
fsck -fn /path

大于20%需要整理。批评fsck不准的文章:大文件碎片少才是最重要的

NEC 的 Akira Fujita 和 Takashi Sato 在十年前就为 ext4 写了在线整理碎片的工具,因此我们直接拿来用就好了。

e4defrag -v /path

碎片整理

方法一:整个磁盘文件备份,格式化,重新搬运。(Liunx 会自动将文件进行连续分布排列。)

方法二:

# 不要中断,很危险
sudo e4defrag /

常见实践问题

为什么出现坏道之后,硬盘会飞速损耗

坏道分为逻辑坏道,和物理坏道。如果是物理坏道,由于异物或者碰撞导致磁头,盘面受损,会导致物理损坏扩散,应该今早备份数据。

并行下载多个两个文件,存储是交叉的吗?读数据会变慢吗?

场景问题:

1.同时向机械硬盘拷贝多个文件夹,每个文件夹里都有多个小文件。 2.同时解压多个压缩包,每个压缩包有大量的小文件。 3.同时安装多个游戏安装包。 会不会导致交叉存储?会不会导致碎片增加?会不会导致游玩游戏时读取速度变慢?

一般不用担心,有些文件系统会做碎片整理。然后操作系统的缓存写和预读策略也会优化。

硬盘的寿命

对于机械硬盘来讲,反复读写、多线程读写等情况对磁盘使用寿命的影响很低, 但“高温”、“低温”、“尘土”、“外力冲击(跌落)”等情况,会对磁盘的寿命造成较大的影响。

需要进一步的研究学习

遇到的问题

暂无

开题缘由、总结、反思、吐槽~~

参考文献

https://learn.lianglianglee.com/%E4%B8%93%E6%A0%8F/%E9%87%8D%E5%AD%A6%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F-%E5%AE%8C/30%20%20%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F%E7%9A%84%E5%BA%95%E5%B1%82%E5%AE%9E%E7%8E%B0%EF%BC%9AFAT%E3%80%81NTFS%20%E5%92%8C%20Ext3%20%E6%9C%89%E4%BB%80%E4%B9%88%E5%8C%BA%E5%88%AB%EF%BC%9F.md

https://www.ruanyifeng.com/blog/2011/12/inode.html

https://tech.meituan.com/2017/05/19/about-desk-io.html

https://zhuanlan.zhihu.com/p/44267768

https://developer.aliyun.com/article/197465

Lock & Synchronization

导言

  • 在PTA开发时,其中的多线程加速经常要用锁来隔离资源和逻辑。

互斥与同步

在进程/线程并发执行的过程中,进程/线程之间存在协作的关系,例如有互斥、同步的关系。

为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法:

  • 基于原子操作指令(汇编) —— 测试和置位(Test-and-Set)指令, 或者(如Compare-And-Swap,CAS)
  • 锁:加锁、解锁操作;
    • 自旋锁(spin lock, 忙等待锁),
    • 无等待锁:思想,把当前线程放入到锁的等待队列,然后执行调度程序
  • 信号量:P、V 操作;

这两个都可以方便地实现进程/线程互斥,而信号量比锁的功能更强一些,它还可以方便地实现进程/线程同步。

常见问题

共享内存加锁之后释放锁,别的进程是如何知道锁释放的

  1. 常用的方法是在共享内存中设置标志位或信号量等,并在共享内存中保证这个标志位或信号量只有在锁被释放时才会被更新。这样,其它进程可以通过轮询或者等待通知的方式来获取锁并开始修改共享内存,从而避免冲突。在共享内存中设置的标志位或信号量通常需要原子操作的支持,以确保并发修改时的正确性。
    1. 轮询:轮询是指线程反复检查某个条件是否成立,直到条件成立为止。在锁机制中,当一个线程持有锁时,其他线程会不断地轮询锁的状态,直到该锁被释放。这种方式的优点是实现简单,不需要额外的通知机制,缺点是占用CPU资源,效率较低。
    2. 等待通知:等待通知是指线程在某个条件不满足时挂起等待,当条件满足时由其他线程通知它继续执行。在锁机制中,当一个线程持有锁时,其他线程会进入等待状态,直到该锁被释放,此时其他线程会被通知并继续执行。这种方式的优点是占用CPU资源少,效率高,缺点是实现稍微复杂一些,需要额外的通知机制。
  2. 另外,也可以使用一个中央锁服务器或者等待队列来管理锁,当一个进程获取锁时,会向中央锁服务器或等待队列发出请求,直到锁被成功获取,并在共享内存中记录锁的状态。当锁被释放时,中央锁服务器或等待队列会通知其它进程,并让其它进程开始自由修改共享内存。

如何保证操作的原子性

  1. 操作系统提供的原子操作:一些操作系统会提供线程安全的原子操作接口,如Compare-And-Swap(CAS)等,它们能够确保指令的原子性,从而保证线程安全。
  2. 事务:事务是指一组操作被视为一个不可分割的操作序列,要么全部执行成功,要么全部失败,具有原子性和一致性保证。常用于数据库操作等场景。
  3. 锁机制:锁机制是一种常用的多线程同步机制,能够确保同一时刻只有一个线程(或进程)可以访问共享资源,从而保证原子性。

如何避免死锁

  1. 避免使用多把锁并且同时持有多个锁。当需要持有多个锁时,可以通过加锁的顺序来避免死锁。如果所有可能的锁按照固定的顺序加锁,那么可以避免死锁。
  2. 设置请求超时时间。当一个进程请求锁时,如果在超时时间内没有获得锁,可以释放之前持有的锁,并尝试重新获取。这样可以避免某一个进程一直持有锁而导致死锁。
  3. 引入随机性。在获取锁的时候加入一些随机因素,让不同的程序在不同的时间获取锁。这样可以防止程序之间在自己的重试过程中的饥饿状态导致的死锁。

悲观锁

  1. 读写操作时,需要预先加锁,防止其他进程对资源的访问。
  2. 通过互斥锁(Mutex)和信号量(Semaphore)来实现。

乐观锁

  1. 在读取或修改共享资源时,并不先进行加锁操作,而是先读取资源,然后在对资源进行写操作时再进行一次比较,看看在这个时间间隔内是否发生了竞争。如果没有发生竞争,就可以将更新后的值写入共享资源,并结束操作;如果发生了竞争,则需要放弃本次更新,并进行重试
  2. 通过版本号的方式来实现。在共享资源中记录该资源的版本号,当一个进程想要修改共享资源时,需要先获取当前资源的版本号。如果当前版本号与自己保存的版本号相符,说明没有其他进程在这段时间内修改该资源,则可以进行写操作;如果版本号已经发生变化,则说明有其他进程对该资源进行了修改,当前进程需要放弃本次写操作,更新版本号,重新获取新的资源,并重新执行操作。

原子操作

Test-and-SetCompare-and-Swap (CAS) 都是并发编程中的重要原子操作指令,通常用于同步和处理多线程或多进程环境中的竞争条件。它们的作用是确保某些操作在执行时不会被中断,以保持数据一致性和避免并发冲突。我们来详细了解这两个命令。

1. Test-and-Set (TAS) 指令

  • 通常用于控制锁定状态,通过设置标志位来获取锁,它返回原始值(通常是锁状态),用于判断锁是否已经被占用。
  • 它通常用于实现自旋锁、互斥锁(mutex)或锁标志位。

它的操作过程如下:

  • Test: 检查指定位置(通常是一个布尔标志位或整数)当前的值。
  • Set: 将这个位置的值设置为 1 或 "true"。

操作是原子的,即不会被中断或干扰,保证在并发环境下,多个线程或进程不能同时修改同一位置。

用途:

  • 实现自旋锁(Spinlock):线程反复检查一个共享变量,如果该变量表示未被锁定,线程会尝试获得锁。
  • 防止竞争条件:确保在并发操作时,只有一个线程能够设置锁标志,其他线程需要等待。
示例

假设有一个共享的锁标志 lock,初始化为 false

lock = false;

while (Test_and_Set(lock)) {
    // 如果 lock 已经被设置为 true,则继续自旋等待
}
// lock 成功设置为 true,获取锁

2. Compare-and-Swap (CAS) 指令

  • 通常用于无锁编程,它通过比较期望值与当前值来决定是否进行更新(检查指定的内存位置的值是否等于一个预期的值,如果相等,则将该位置的值替换为新的值),适用于复杂的数据结构操作。
  • 常用于无锁数据结构和其他更高效的并发操作。

它的操作步骤如下:

  • Compare: 比较目标内存位置的当前值是否与预期值相等。
  • Swap: 如果当前值等于预期值,就将目标位置的值替换为新的值。如果不相等,则什么也不做。

CAS 也具有原子性,这意味着它在操作期间不会被中断。CAS 是实现无锁编程和避免线程同步问题的常用工具,尤其在需要高性能的并发程序中。

用途:

  • 实现无锁数据结构(如无锁队列、栈、哈希表等)。
  • 实现线程安全的计数器或标志位操作。
  • 用于一些算法(如版本控制或并发计数器)中,确保多个线程不冲突地更新共享数据。
示例:

假设有一个变量 x,初始值为 10:

int expected = 10;
int new_value = 20;

if (Compare_and_Swap(&x, expected, new_value)) {
    // 如果 x 的值为 expected (10),就将 x 更新为 new_value (20)
} else {
    // 如果 x 的值不为 expected,CAS 操作失败
}

3. TAS的汇编实现

  • Test-and-Set (TAS) 操作通常可以通过 XCHG 指令(交换)在 x86 汇编中实现。虽然 x86 没有直接的 Test-and-Set 指令,XCHG 被用作实现类似功能的原子操作。
例如,在 x86 中:
; x86 示例,使用 XCHG 实现 Test-and-Set
; 假设锁变量位于 memory_location
; eax 是存储旧值的寄存器
mov eax, 0          ; eax 设为 0,表示“未锁定”
xchg eax, [lock]    ; 将 eax 与锁变量交换,如果锁变量是 0,就设置为 1
                    ; 如果返回的 eax 仍为 0,说明锁成功设置

这个操作会交换 eax[lock] 位置的值,并将原值存储在 eax 中。如果 [lock] 最初是 0eax 将变为 1,并且 lock 会被设置为 1,表示锁已经被占用。

虽然 Test-and-Set 并不一定直接映射到汇编中的特定指令,但它可以通过交换指令来模拟。

4. CAS的汇编实现

  • Compare-and-Swap (CAS) 指令在 x86 中有专门的 CMPXCHG 指令,ARM 等其他架构也有类似的原子操作指令(如 LDREXSTREX)。
例如,在 x86 中:

x86 提供了 CMPXCHG(比较和交换)指令,它就是 CAS 操作的硬件实现。其语法如下:

CMPXCHG destination, source
  • destination:是要比较的内存位置。
  • source:是用于替换的值。
  • 如果 destination 中的值与 source 中的值相等,destination 将被更新为 source 的值,且 AX 寄存器的值将保存 destination 原先的值。
  • 如果不相等,destination 的值保持不变,并且 AX 寄存器将保存 destination 的原值。
; x86 示例,使用 CMPXCHG 实现 Compare-and-Swap
; 假设目标内存位置是 lock,期望值是 eax,新的值是 ebx

mov eax, expected_value    ; 将期望的值加载到 eax
mov ebx, new_value        ; 将新的值加载到 ebx
cmpxchg [lock], ebx        ; 比较 [lock] 的值与 eax(期望值),如果相等则将 [lock] 更新为 ebx(新值)
                        ; 如果不相等,eax 会保存 [lock] 的原值

这个 CMPXCHG 指令会首先将内存中的值与 eax 比较,如果两者相等,它会将 ebx 的值存储到内存中,并且 eax 会保存原来的值;如果两者不相等,内存中的值不会改变,而 eax 会保存当前内存值,指示操作失败。

在 ARM 架构中:

ARM 也提供了类似的原子操作指令,称为 LDREXSTREXLDREX 加载一个值并锁定它,STREX 则用于条件性地写回值。

; ARM 示例,使用 LDREX 和 STREX 实现 CAS
LDREX R0, [lock]    ; 加载锁的当前值到 R0
CMP R0, R1           ; 比较 R0(当前锁的值)与 R1(期望值)
BNE CAS_FAIL         ; 如果不相等,跳转到失败处理
STREX R2, R3, [lock] ; 如果相等,将 R3(新值)写入 [lock],并将成功标志存储在 R2

atomic 封装

std::atomic 是对 Test-and-SetCompare-and-Swap 等底层原子操作的高级封装,使得 C++ 开发者可以在多线程环境中更方便、更安全地使用这些原子操作。

原子操作(std::atomic)有如下特点:

  • 线程安全:原子操作保证了即使在多线程环境下也能正确地修改和读取共享变量。
  • 高效:没有加锁开销,适用于需要频繁检查和修改标志的场景。
  • 原子操作的限制std::atomic 只能用于一些简单的类型(如整数、指针和布尔类型),对于复杂的数据结构,仍然需要其他同步机制(如锁)。

示例:使用 std::atomic<bool> 控制初始化

在控制初始化只执行一次的场景中,你可以使用 std::atomic<bool> 来替代普通的 bool 类型,这样就能确保线程安全地检查和设置初始化标志。

#include <atomic>

std::atomic<bool> isInited(false);

void initFunction() {
    // 原子操作,检查并更新 isInited
    if (!isInited.exchange(true)) { // mutex  std::call_once 和std::once_flag 也可以实现类似功能。
        // 执行初始化逻辑
        // ...
    }
}
  1. std::atomic<bool> isInited(false);:声明一个原子类型的 bool 变量,初始值为 false
  2. isInited.exchange(true):调用 exchange 方法,该方法会将 isInited 的当前值返回,并将其更新为 true。如果当前值为 false,说明还没有初始化,此时会执行初始化逻辑。
  3. 如果 isInited 已经是 true,则 exchange 会返回 true,跳过初始化部分。

避免直接读取 std::atomic 对象, 使用load store

std::atomic<int> counter(0);
counter = 5;  // 这种赋值操作不是原子操作,可能导致线程不安全。
int value = counter;  // 直接读取可能没有同步, 导致缓存不一致问题,多个线程读取的值不一定是最新的。

原子操作是不会失败的

  • load、store 和 exchange 都不会失败,它们保证原子操作的顺序性和正确性。
  • compare_exchange_strong 和 compare_exchange_weak 是可能会“失败”的操作,它们只有在预期值和原子变量的值一致时才会成功执行,否则会返回 false,并更新预期值。

内存顺序:std::memory_order

对于一个线程里的同一个代码块内的无依赖关系的指令,处理器在运行时会重排序乱序执行。这对原子指令也是一样的。但实际生产中,多个原子操作之间,可能会有依赖关系,比如一个线程读取另一个线程写入的数据。为了确保这些依赖关系的正确性,需要使用内存顺序来控制处理器对指令的重排序。

std::atomic 的操作支持指定内存顺序,这控制了编译器和硬件对操作的优化和重排序。常见的内存顺序选项有:

  • std::memory_order_relaxed:没有限制,运行时会乱序。
  • std::memory_order_acquirestd::memory_order_release:分别用于“获取”和“释放”同步,确保操作顺序。
    • std::memory_order_acquire:保证在此操作之前的所有操作不会被重排序到它之后。
    • std::memory_order_release:保证在此操作之后的所有操作不会被重排序到它之前。
  • std::memory_order_seq_cst默认的内存顺序,保证在多线程程序中,所有原子操作按顺序执行,不允许重排序。但性能可能会略受影响。
memory_order_acquire

这两者提供了获取和释放同步的保证:

  • std::memory_order_acquire:保证在此操作之前的所有操作不会被重排序到它之后。
  • std::memory_order_release:保证在此操作之后的所有操作不会被重排序到它之前。
#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter(0);
std::atomic<bool> ready(false);

void producer() {
    counter.store(42, std::memory_order_release);  // 确保 counter 写入在 ready 之前
    ready.store(true, std::memory_order_release);  // 标记准备好
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {}  // 等待 ready 为 true
    std::cout << "Counter: " << counter.load(std::memory_order_acquire) << std::endl;
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);

    t1.join();
    t2.join();

    return 0;
}

解释

  • consumer 线程在 ready.load(std::memory_order_acquire) 后才能读取 counter,确保 ready 被设置为 true 后,counter 的写入才是可见的。
  • memory_order_release 确保 producer 线程的 counter 写操作不会被重排序到 ready.store 之前。
  • memory_order_acquire 确保 consumer 在读取 ready 后,才能正确地读取到 counter 的值。

1. 直接赋值:store

在原子操作中,store 用来将一个值存储到原子变量中。与普通赋值不同,store 会确保在多线程环境下,赋值操作是原子性的(即不会被打断)。

std::atomic<int> counter(0);
counter.store(5, std::memory_order_relaxed);  // 存储 5
- store 会保证在指定的内存顺序下将值存储到原子变量中,因此它是线程安全的。

2. 加载:load

load 用于从原子变量中读取值。它会确保在多线程环境中,读取操作是线程安全的,并且可以指定内存顺序。

int value = counter.load(std::memory_order_acquire);  // 从原子变量读取
  • load 会确保读取的是正确同步后的值,避免在多线程场景下出现读取错误。

3. 比较并交换:compare_exchange_strong

  • compare_exchange_strong(以及 compare_exchange_weak)广泛应用于实现锁和无锁算法。它会尝试将原子变量的值与一个预期值进行比较,如果相同,则将其更新为新值。如果比较失败,原子变量的值不会更改,并且返回 false
compare_exchange_strong 与compare_exchange_weak 的区别
  • compare_exchange_strong 是标准的、严格的 Compare-and-Swap 操作,失败时立即返回,并不会自动重试。
  • compare_exchange_weak 是一种可能会在某些实现中自动重试的版本,适用于高并发场景,特别是在竞争激烈时可以减少操作的开销。
  • 虽然它们有细微的区别,但两者的核心作用是相同的:原子地比较并更新共享变量。在选择使用哪种方法时,主要取决于你对性能的要求以及是否需要在失败时进行重试。
std::atomic<int> value(0);
int expected = 0;
int desired = 42;

if (value.compare_exchange_strong(expected, desired)) {
    std::cout << "CAS succeeded! Value updated to " << value.load() << "\n";
} else {
    std::cout << "CAS failed! Expected: " << expected << ", Actual: " << value.load() << "\n";
}

// 尝试比较并交换
while (!value.compare_exchange_weak(expected, desired)) {
    std::cout << "CAS failed, retrying...\n";
    // 可以执行一些其他的操作或稍微延迟再重试
}
std::cout << "CAS succeeded! Value updated to " << value.load() << "\n";

4. exchange

exchange 是一种常见的原子操作,类似于 store,但它会返回原子变量的先前值。

int oldValue = counter.exchange(5);  // 返回更新前的值,并将 counter 设置为 5
  • exchange 在多线程环境下是安全的,并且可以返回修改前的值,适用于某些需要获取旧值的场景。

常见锁的优缺点和实现

自旋锁(spinlock)

多线程同步的一种忙等待锁,线程反复检查锁变量是否可用。

  • 优点:避免了操作系统进程调度和线程切换,所以自旋锁通常适用在时间比较短的情况下。由于这个原因,操作系统的内核经常使用自旋锁。
  • 缺点:如果长时间上锁的话,自旋锁会非常耗费性能,它阻止了其他线程的运行和调度
    • 线程持有锁的时间越长,则持有该锁的线程将被 OS(Operating System) 调度程序中断的风险越大。
  • 解决办法:
    • TicketLock 是采用排队叫号的机制。
    • CLHLock和MCSLock通过链表的方式避免了减少了处理器缓存同步,极大的提高了性能,区别在于CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态。
std::atomic_flag lock = ATOMIC_FLAG_INIT;

void spinlock() {
    while (lock.test_and_set(std::memory_order_acquire)) {
        // 自旋等待,直到锁被释放
    }
    // 临界区代码
    lock.clear(std::memory_order_release);  // 解锁
}

互斥锁(mutex)

把自己阻塞起来(内核态和用户态之间的切换进入阻塞状态,可能上下文切换),等待重新调度请求。

  • 互斥锁的实现
    1. 软件实现:软件互斥锁需要借助操作系统提供的原子操作(如Compare-And-Swap,CAS)来实现 优点是灵活性高 缺点是性能较低,
    2. CAS操作需要三个参数,内存地址A,期望值V,新值N。执行过程如下:
      • 读取内存地址A的原始值,保存在临时变量Value中
      • 比较Value和期待值V是否相等,如果相等则将内存地址A的值更新为新值N
      • 如果内存地址A的值已经被其他线程改变,则不进行更新操作
  • TAS(test and set)
    • 一个TAS指令包括两个子步骤,把给定的内存地址设置为1,然后返回之前的旧值。
    • 硬件实现:硬件互斥锁使用计算机硬件提供的特殊指令(如锁总线指令)来实现。当线程要获取锁时,它会发出一个锁总线指令,这个指令会占用系统总线,使得其他CPU无法读写内存。
      1. 当lock前缀指令执行时,它将锁定处理器总线,确保其他处理器无法同时访问同一内存区域,
代码实例:lock_guard

std::lock_guard<std::mutex> 是 C++ 标准库中的一个 RAII(Resource Acquisition Is Initialization)类,它用于在作用域内自动锁住一个 std::mutex,并在作用域结束时自动解锁。它可以帮助你避免手动管理锁,减少死锁和忘记解锁的风险。

用法

std::lock_guard 通过构造时自动锁住互斥量,并且在作用域结束时自动释放互斥量的锁。因此,你不需要手动调用 mutex.unlock()。只需要确保 lock_guard 的生命周期结束时,互斥量会被自动解锁。

示例代码

#include <iostream>
#include <mutex>
#include <thread>

std::mutex init_mutex_;  // 互斥量,防止多个线程同时初始化

void some_function() {
    // 创建一个 lock_guard 对象,自动锁住 init_mutex_
    std::lock_guard<std::mutex> lock(init_mutex_);

    // 在此作用域内,init_mutex_ 被锁定
    std::cout << "Doing some work inside the critical section...\n";

    // lock_guard 在作用域结束时会自动解锁 mutex
}

int main() {
    std::thread t1(some_function);
    std::thread t2(some_function);

    t1.join();
    t2.join();

    return 0;
}

解释

  1. std::lock_guard<std::mutex> lock(init_mutex_);
  2. 在这行代码中,lock_guard 对象被创建,并立即锁住 init_mutex_
  3. std::lock_guard 的构造函数会调用 mutex.lock() 来锁定互斥量。
  4. lock 变量在作用域结束时自动销毁,这会触发析构函数 lock_guard::~lock_guard(),并自动解锁互斥量(即调用 mutex.unlock())。

  5. some_function 中,互斥量在整个函数内都是被锁定的,因此其他线程无法进入这段代码,避免了并发冲突。

为什么使用 std::lock_guard

  • 避免死锁std::lock_guard 会自动解锁互斥量,减少了忘记解锁的风险。
  • 简洁易懂:它为你管理锁,减少了显式的锁管理代码,使代码更简洁。
  • 线程安全:当多个线程访问同一资源时,std::lock_guard 可以确保每次只有一个线程能够访问临界区,防止数据竞争。

适用场景

通常用于在函数、代码块中保护共享资源。它适合需要访问互斥量的代码块,但不想手动管理锁和解锁时。

代码实例:cv.wait 实现多线程的先后顺序
  1. 使用 std::thread 和 std::mutex + std::condition_variable

如果你希望在销毁环境的 Final 函数中等待某些子线程完成,可以使用 std::mutex 和 std::condition_variable 来同步子线程的完成状态。

示例代码: 假设你有一个主线程(执行 Final 函数)和一个子线程,主线程需要等待子线程完成后才能销毁环境。

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <atomic>

std::mutex mtx;
std::condition_variable cv;
std::atomic<bool> thread_done{false};

void worker_thread() {
    std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟工作
    std::cout << "Worker thread finished." << std::endl;
    thread_done = true;  // 设置线程完成标志
    cv.notify_one();     // 通知主线程
}

void Final() {
    std::cout << "Final: Waiting for worker thread to finish..." << std::endl;

    // 等待子线程完成
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return thread_done.load(); });
    // cv.wait(lock, [this] { return thread_done.load(); }); // in class

    std::cout << "Final: Worker thread finished. Proceeding with cleanup." << std::endl;
}

int main() {
    // 启动子线程
    std::thread t(worker_thread);

    // 在主线程中调用 Final
    Final();

    t.join();  // 等待子线程结束

    return 0;
}

关键点:

  • std::mutex mtx: 用于锁住条件变量,确保线程间的同步。
  • std::condition_variable cv: 用于实现主线程等待子线程的完成。
  • std::atomic<bool> thread_done: 用于标记子线程是否完成。
  • cv.wait(lock, condition): 主线程会等待子线程设置 thread_done 为 true,然后才会继续执行。

读写锁(ReadWrite Lock)

  • 在读操作和写操作之间提供了更细粒度的同步控制。
  • 多个线程可以同时获取读锁,但只有一个线程能够获取写锁。
    • 读写锁有三种状态:读加锁状态、写加锁状态和不加锁状态
    • 规则
    • 当读写锁在写加锁模式下,任何试图对这个锁进行加锁的线程都会被阻塞,直到写进程对其解锁。
    • 当读写锁在读加锁模式先,任何线程都可以对其进行读加锁操作,但是所有试图进行写加锁操作的线程都会被阻塞,直到所有的读线程都解锁。
  • 缺点:当读者源源不断到来的时候,写者总是得不到读写锁,就会造成不公平的状态。
    • 避免方法: 当处于读模式的读写锁接收到一个试图对其进行写模式加锁操作时,便会阻塞后面对其进行读模式加锁操作的线程。这样等到已经加读模式的锁解锁后,写进程能够访问此锁保护的资源。
  • 优点:
    • 读写锁可以提高并发性,允许多个线程同时读取数据,而只有在需要修改数据时才会互斥。
    • 适合对数据结构读的次数远远大于写的情况。

RCU(Read-Copy-Update)

  • 对读写锁的一种改进。适用于读多写少场景的数据同步机制。
  • 具体内容
    • 并发读取数据不再需要加锁
    • 写数据时,RCU机制通过创建一个副本来实现读写分离,确保在更新过程中没有线程正在读取旧的数据。
      • 写者修改数据前首先拷贝一个被修改元素的副本,然后在副本上进行修改,修改完毕后它向垃圾回收器注册一个回调函数以便在适当的时机执行真正的修改操作。
      • 读者必须提供一个信号给写者以便写者能够确定数据可以被安全地释放或修改的时机。
      • 有一个专门的垃圾收集器来探测读者的信号,一旦所有的读者都已经发送信号告知它们都不在使用被RCU保护的数据结构,垃圾收集器就调用回调函数完成最后的数据释放或修改操作。

通知与同步

适用于大段的修改和同步。

eventfd 事件通知

eventfd 是 Linux 提供的一种用于线程安全的事件通知机制,类似于文件描述符,可以通过读写操作来实现跨线程或跨进程的同步。通过 eventfd,线程或进程可以通过某些事件(例如计数器增减、通知等)来触发其他线程或进程的动作。

eventfd 可以通过两种方式工作:

  • 计数器模式:通过 eventfd_write 增加计数器,其他线程或进程可以通过 eventfd_read 来读取这些计数器的值。
  • 通知模式:使用 eventfd 进行事件通知,线程或进程通过 eventfd_read 来等待并响应这些事件。

eventfd_read 的功能 : eventfd_read 是一个用于从 eventfd 文件描述符读取事件的系统调用。它会从 eventfd 中读取一个 64 位无符号整数,并返回这个值。这个值通常表示某个事件的状态或计数器的值。

线程间同步

如果两个线程同时对同一个 eventfd 文件描述符进行读写操作:

  • eventfd_read 会阻塞直到 eventfd 中有事件(即计数器的值大于 0),并且在读取之后会减少计数器。
  • eventfd_write 会增加计数器的值,并通知正在等待的线程。

函数原型

#include <sys/eventfd.h>

ssize_t eventfd_read(int efd, uint64_t *value);
ssize_t eventfd_write(int efd, uint64_t value);
  • efdeventfd 文件描述符。
  • * value:指向一个 uint64_t 类型的变量,用于存储从 eventfd 读取的值。
  • value:要写入 eventfd 的值,通常是一个 64 位无符号整数 (uint64_t),表示事件计数器的增加量或特定事件的通知值。

返回值

  • 如果成功,eventfd_read 返回读取到的字节数,通常为 8(因为它读取一个 64 位的值)。
  • 如果失败,返回 -1,并将 errno 设置为合适的错误码。

错误码

  • EAGAINeventfd 中没有事件可读(即没有数据)。
  • EBADF:无效的文件描述符。
  • 其他常见的文件描述符错误。

代码示例

假设你使用 eventfd 来同步不同的线程:

#include <sys/eventfd.h>
#include <unistd.h>
#include <iostream>
#include <cstring>

int main() {
    // 创建 eventfd 文件描述符
    int efd = eventfd(0, EFD_NONBLOCK);
    if (efd == -1) {
        std::cerr << "Failed to create eventfd: " << strerror(errno) << std::endl;
        return -1;
    }

    // 写入事件
    uint64_t u = 1; // 写入一个事件值
    ssize_t s = write(efd, &u, sizeof(u));
    if (s == -1) {
        std::cerr << "Failed to write to eventfd: " << strerror(errno) << std::endl;
        close(efd);
        return -1;
    }

    // 读取事件
    uint64_t read_value;
    s = eventfd_read(efd, &read_value);
    if (s == -1) {
        std::cerr << "Failed to read from eventfd: " << strerror(errno) << std::endl;
    } else {
        std::cout << "Read value: " << read_value << std::endl;
    }

    close(efd);
    return 0;
}

概念题

RedStar (小红书) 笔试:图中有依赖的任务的,需要几个信号量来实现同步

CSDN,有一条依赖线,需要一个信号量

在使用信号量(Semaphore)进行线程同步时,P(proberen)和V(verhogen)操作是非常重要的概念。

  1. P操作(也称为Wait操作或Down操作):

  2. 表示获取或等待信号量。

  3. 如果信号量内部计数值大于0,获取信号量并将计数值减1。
  4. 如果计数值等于0,线程将等待,直到计数值大于0。如果信号量的值大于0,表示资源可用,进程可以继续执行。如果信号量的值为0,表示资源不可用,P操作将阻塞(即等待)进程,直到该信号量的值大于0为止。

伪代码表示为:

P(S):
  while S <= 0:
    // 等待,直到S大于0
  S = S - 1
  1. V操作(也称为Signal操作或Up操作):

  2. 表示释放或增加信号量。

  3. 将信号量内部计数值加1。
  4. 如果存在等待线程,唤醒其中一个线程继续执行。

伪代码表示为:

V(S):
  S = S + 1

P和V操作保证了对共享资源的互斥访问。

一个线程使用P操作等待获取信号量,V操作在使用完共享资源后释放信号量。

信号量的值通常用于控制共享资源的数量,它可以是非负整数。当信号量被初始化为1时,称为二进制信号量(Binary Semaphore),因为它只能取0或1的值,通常用于实现互斥访问临界区。如果信号量的值大于1,称为计数信号量,可用于限制对资源的并发访问数。

在实际编程中,P操作和V操作通常是原子操作,确保在多线程或多进程环境下的正确同步和竞争条件的安全处理。

TP-link笔试:设计的程序在多个CPU上运行时,不应使用哪个实现多个CPU间的数据访问同步?

参考文献

https://www.cswiki.top/pages/f398f1/#blocking-i-o

原文链接:https://blog.csdn.net/qq_15437629/article/details/79116590

https://zhuanlan.zhihu.com/p/161936748