首页
留言
关于
统计
友链
Search
1
[C++Quiz]#4、#116、#174
1 阅读
2
C/C++选手初学Python可能遇到的问题
0 阅读
3
[C++Quiz]#27、#41、#44
0 阅读
4
[C++Quiz]#185、#207、#219
0 阅读
5
[C++]std::variant
0 阅读
C/C++
数据结构与算法
CSLab
xv6
bustub
6.5840
数据库
doris
SQL
做个数据库
工具
CS
登录
Search
标签搜索
C++
cppquiz
xv6
算法
doris
论文
os
CS
leetcode
Git
tools
并发
6.5840
Koarz
累计撰写
24
篇文章
累计收到
4
条评论
首页
栏目
C/C++
数据结构与算法
CSLab
xv6
bustub
6.5840
数据库
doris
SQL
做个数据库
工具
CS
页面
留言
关于
统计
友链
搜索到
24
篇与
的结果
2024-07-14
[CS]并发安全--基本锁
文章对三种常见锁(自旋锁 互斥锁 读写锁)进行介绍以及实现, 并讨论各自的性能问题自旋锁自旋锁是计算机科学用于多线程同步的一种锁,线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待。一旦获取了自旋锁,线程会一直保持该锁,直至显式释放自旋锁。自旋锁避免了进程上下文的调度开销,因此对于线程只会阻塞很短时间的场合是有效的。因此操作系统的实现在很多地方往往用自旋锁。简单来说就是, 如果我们让一个自旋锁lock那么在它unlock前其他线程想获取这个自旋锁时就会一直等待, 线程一直处于running状态避免了上下文切换的开销, 这种操作有利有弊, 好处就是前边提到的避免上下文切换(操作系统)的开销, 坏处就是, 如果一个线程持有锁的时间很长或者有许多个线程都想持有这个互斥锁, 那么简直就是性能灾难!!!提到自旋锁很多人都会提到CAS(compare and swap), CAS操作基于CPU提供的原子操作指令实现。CAS功能等价于下边这部分代码bool cas(int *addr, int old_v, int new_v) { if(*addr != old_v) return false; *addr = new_v; return true; }在C++中有这种功能的函数是atomic_compare_exchange_weak(strong), 不过这个函数的作用跟CAS并不完全一样, 所以自己实现自旋锁的话需要加一步, 以下是一个简单的spin_lock的实现:class spin_lock { std::atomic_bool locked{false}; public: void lock() { bool t{false}; while (!locked.compare_exchange_weak(t, true)) { t = false; } } void unlock() { locked = false; } };compare_exchange_weak比较恶心的点就是第一个参数(这里我使用了atomic对象的内部的函数)必须是左值, 所以还得额外定义一个bool然后呢如果locked不是false, 它还会把t改为true...所以每次循环还得把t改回来...这样的效率简直太低了为什么不直接判断locked的值再根据locked的值修改呢? 因为这种操作并不是原子的你可以想一下这句:while (true) { if (!locked) { locked = true; break; } }在locked=false的条件下, 可能有不止一个线程同时判断出!locked为true, 他们都进入了if为true的作用域, 并都将locked改为true之后离开lock()函数, 它们都认为自己持有了锁, 实际上并不是这样, 这种实现就导致锁不能很好的保护数据了互斥锁互斥锁跟自旋锁一样, 同样一次只能由一个线程持有, 不同的是互斥锁在获取锁失败后线程会被挂起, 而不是一直running根据互斥锁的特性, 我们可以类比自旋锁, 当lock失败后线程挂起, 在C++中可以通过函数wait()和notify_one()来实现(需要C++20)class mutex { std::atomic_bool locked{false}; public: void lock() { bool t{false}; while (!locked.compare_exchange_weak(t, true)) { locked.wait(true); t = false; } } void unlock() { locked = false; locked.notify_one(); } };我测试了一下20个线程情况下while共执行多少次, 大概就是19次, 200个线程大概是199次上下浮动很小, 也就是基本上每个线程(除了第一个获取锁的线程)的while都基本上只执行一次, 如果是自旋锁, while执行次数就是灾难, 在这里就需要消耗大量的CPU资源...不过我也不知道为什么还会有浮动, 因为每次unlock都只唤醒一个线程才对, 求解答读写锁读写锁通过维护引用计数来将读锁与写锁分开, 读写锁适用于读多写少的场景, 它允许同一时间存在大量的读操作, 只存在一个写操作, 写操作与读操作互斥, 写写互斥根据其特性, 我们可以这样实现读写锁:class rwlock { std::atomic_int total_{}; public: void read_lock() { while (true) { int t{total_}; if (t < 0) { continue; } if (total_.compare_exchange_weak(t, t + 1)) { return; } } } void write_lock() { int t{0}; while (!total_.compare_exchange_weak(t, -1)) { t = 0; } } void unlock() { int t{-1}; if (!total_.compare_exchange_weak(t, 0)) { total_--; } } };通过前边的铺垫应该可以看懂是怎么实现的了, 引用计数为-1时表示当前是写锁持有, 正数是读锁的引用计数, read_lock中需要不断读取计数值并比较, 根据不同情况选择更新引用计数 or 继续执行循环, write_lock就是跟互斥(自旋)锁一样, 等待引用计数归零原子操作的弊端通过以上对各个锁的实现可以看出, 它们都使用了原子操作, 原子变量, 我以前以为原子操作是无副作用的, 但是这个观点大错特错当我们尝试对某一个变量进行原子地修改时修改它的CPU会给其他CPU发送信息, 告诉它们: "嗨, 你现在缓存的这个变量的值被我改了, 你再重新读一下", 就是这样, 在有大量线程的情况下, 对原子变量的写操作将导致性能大大下降正是因为有这样的麻烦, 所以才发展了无锁编程, 后边我会介绍一些无锁技术
2024年07月14日
0 阅读
0 评论
0 点赞
2024-04-23
[6.S081]journal-design论文翻译
原文journal-designIntroduction文件系统是任何现代操作系统的核心部分,人们期望它们既快速又极其可靠。然而,问题仍然会发生,机器可能会因为硬件、软件或电源故障而意外宕机。在意外重启之后,系统可能需要一段时间才能将其文件系统恢复到一致状态。随着磁盘容量的增长,这段时间可能成为一个严重问题,导致系统下线一小时甚至更长时间,因为磁盘需要进行扫描、检查和修复。尽管磁盘驱动器每年都在变得更快,但与它们巨大的容量增加相比,这种速度增长是适度的。不幸的是,每个磁盘容量翻倍都会导致使用传统文件系统检查技术时恢复时间翻倍。在系统可用性至关重要的情况下,这可能是无法割舍的时间,因此需要一种机制来避免每次机器重启时都需要昂贵的恢复阶段。What’s in a filesystem?任何文件系统都需要满足一定的功能要求,这些要求通常由文件系统所服务的操作系统所决定。文件系统在应用程序中的表现方式是其中之一,操作系统通常要求文件名遵循特定的约定,并且文件具有特定的属性,这些属性以特定的方式进行解释。然而,文件系统的许多内部方面并没有受到如此严格的限制,文件系统的实现者可以在一定程度上自由设计。数据在磁盘上的布局(或者在非本地文件系统的情况下,可能是网络协议)、内部缓存的详细信息以及用于调度磁盘I/O的算法,这些都是可以在不必违反文件系统应用程序接口规范的情况下进行更改的事项。我们选择一种设计而不是另一种设计的原因有很多。与旧文件系统的兼容性可能是一个问题:例如,Linux提供了UMSDOS文件系统,它在标准的MSDOS磁盘文件结构之上实现了POSIX文件系统的语义。在尝试解决Linux上长时间文件系统恢复时间的问题时,我们牢记了一些目标:使用新文件系统不应严重影响性能;不得破坏与现有应用程序的兼容性;文件系统的可靠性不得以任何方式受损。Filesystem Reliability在谈论文件系统可靠性时,有许多问题需要考虑。针对这个特定项目,我们主要关注在文件系统崩溃时能够可靠地恢复内容的可靠性,我们可以确定以下几个方面:保护性:在崩溃之前在磁盘上稳定的数据绝不能受到损坏。显然,正在写入磁盘的文件在崩溃时不能保证完全完好,但是任何已经安全保存在磁盘上的文件都不能被恢复系统触及。可预测性:我们需要可预测的故障模式,以便可靠地进行恢复。原子性:许多文件系统操作需要完成大量单独的IO操作。一个很好的例子是将文件从一个目录重命名为另一个目录。如果这种文件系统操作在磁盘上完全完成或在恢复完成后完全撤销,那么恢复就是原子的。(例如,在重命名示例中,恢复应该在崩溃后将旧文件名或新文件名提交到磁盘,但不应同时存在两者。)Existing implementationsLinux的ext2fs文件系统提供了保护性恢复,但是它是非原子的且不可预测的。实际上,可预测性是一个比表面看起来更复杂的属性。为了能够可靠地在崩溃后清理,恢复阶段必须能够确定在磁盘上遇到不一致性的情况下文件系统当时正在尝试做什么。一般来说,这需要文件系统在单个更新操作更改多个磁盘块时以可预测的顺序将其写入磁盘。有许多实现磁盘写入顺序的方法。最简单的方法是在提交下一个写入设备驱动程序之前等待第一个写入完成,即“同步元数据更新”方法。这是BSD快速文件系统采用的方法,它出现在4.2BSD中,启发了许多后来的Unix文件系统,包括ext2fs。然而,同步元数据更新的主要缺点是其性能。如果文件系统操作需要等待磁盘IO完成,那么我们就无法将多个文件系统更新批量写入单个磁盘。例如,如果我们在同一个目录块上创建了十几个目录条目,那么同步更新要求我们将该块分别写回磁盘十几次。有几种方法可以解决这个性能问题。一种保持磁盘写入顺序的方法,而不必等待IO完成,是在内存中维护磁盘缓冲区之间的顺序,并确保当我们最终写回数据时,直到所有前置块都安全地写入磁盘后,我们才写入一个块,即“延迟有序写入”技术。延迟有序写入的一个复杂之处在于很容易陷入缓存缓冲区之间存在循环依赖的情况。例如,如果我们试图在两个目录之间重命名文件,并同时将另一个文件从第二个目录重命名到第一个目录,则最终会出现两个目录块互相依赖的情况:在另一个目录块写入磁盘之前,不能将任何一个目录块写入磁盘。Ganger的“软更新”机制巧妙地避开了这个问题,如果在我们第一次尝试将缓冲区写入磁盘时,仍存在未解决的依赖关系,则有选择地回滚缓冲区内特定的更新。一旦所有依赖关系都得到满足,缺失的更新将稍后恢复。这使我们能够按任意顺序写出缓冲区,即使存在循环依赖。软更新机制已被FreeBSD采用,并将作为其下一个主要内核版本的一部分提供。然而,所有这些方法都存在一个共同的问题。尽管它们确保整个文件系统操作过程中磁盘的状态是可预测的,但恢复过程仍然需要扫描整个磁盘才能找到并修复任何未完成的操作。恢复变得更可靠,但不一定更快。然而,可以通过不牺牲可靠性和可预测性来加速文件系统恢复。这通常是由保证文件系统更新原子完成的文件系统来实现的(在这些系统中,单个文件系统更新通常称为事务)。原子更新背后的基本原理是,文件系统可以将一整批新数据写入磁盘,但是这些更新在进行最终提交更新之前不会生效。如果提交涉及将单个块写入磁盘,则崩溃只能导致两种情况:要么已经将提交记录写入磁盘,此时可以假定所有已提交的文件系统操作在磁盘上都是完整和一致的;要么提交记录丢失,在这种情况下,我们必须忽略由于崩溃时尚未提交的部分不完整更新而导致的任何其他写入。这自然需要文件系统更新将更新数据的旧版本和新版本都保留在磁盘上的某个地方,直到提交时刻。实现这一点有多种方法。在某些情况下,文件系统将更新数据的新副本保存在与旧副本不同的位置,并且一旦更新被提交到磁盘,就会最终重新使用旧空间。Network Appliance的WAFL文件系统采用了这种方法,它通过维护一个可以通过简单地将树节点复制到新位置并然后在树的根部更新一个磁盘块来以原子方式更新树形文件系统数据。日志结构文件系统通过将所有文件系统数据(包括文件内容和元数据)连续写入磁盘流(“日志”)来实现相同的目的。使用这种方案查找数据的位置可能比传统文件系统更复杂,但是日志具有一个很大的优势,即相对容易在日志中放置标记,以指示在某个点之前所有数据都已提交并且在磁盘上一致。由于日志的性质使得大多数写入以连续的流进行且无需磁盘寻道,因此写入这样的文件系统特别快。许多文件系统都是基于这种设计编写的,包括Sprite LFS和Berkeley LFS。Linux上也有一个原型LFS实现。最后,还有一类原子更新的文件系统,其中未完成更新的旧版本和新版本通过将新版本写入磁盘上的不同位置来保留,直到更新提交为止。提交后,文件系统可以将更新后的磁盘块的新版本写回磁盘上的原位置。这就是“日志”(有时称为“日志增强”)文件系统的工作方式。当更新磁盘上的元数据时,更新将记录在保留用作日志的磁盘的单独区域中。完成的文件系统事务将在日志中添加一个提交记录,只有在提交安全写入磁盘后,文件系统才能将元数据写回其原始位置。事务是原子的,因为在崩溃后我们总是可以根据日志是否包含事务的提交记录来撤消事务(丢弃日志中的新数据)或重做事务(将日志副本复制回原始副本)。许多现代文件系统都采用了此设计的变体。Designing a new filesystem for Linux这种针对Linux的新文件系统设计的主要动机是消除崩溃后长时间的文件系统恢复时间。因此,我们选择了文件系统日志记录方案作为工作的基础。日志记录通过始终记录磁盘上潜在不一致的所有数据到日志中,实现了快速的文件系统恢复。因此,文件系统恢复可以通过扫描日志并将所有已提交的数据复制回主文件系统区域来实现。这样做是快速的,因为日志通常远小于完整的文件系统。它只需要足够大的空间来记录几秒钟的未提交更新。选择日志记录还有另一个重要优势。与传统文件系统不同,日志记录文件系统将临时数据保存在新位置,与磁盘上的永久数据和元数据无关。因此,这种文件系统不要求永久数据以任何特定方式存储。特别是,可以在新文件系统中使用ext2fs文件系统的磁盘结构,并将现有的ext2fs代码用作日志记录版本的基础。因此,我们并不是为Linux设计一个新的文件系统。相反,我们正在向现有的ext2fs添加一个新功能——事务性文件系统日志记录。Anatomy of a transaction在考虑日志记录文件系统时的一个核心概念是事务,对应于文件系统的单个更新。每个应用程序发出的单个文件系统请求都会导致一个事务,其中包含由该请求产生的所有已更改的元数据。例如,对文件的写入将导致对磁盘上文件inode中的修改时间戳的更新,并且如果写入扩展了文件,则可能还会更新长度信息和块映射信息。如果为文件分配了新的块,那么配额信息、空闲磁盘空间和已使用块位图都必须更新,并且所有这些都必须记录在事务中。在事务中还有另一个隐藏的操作,我们必须意识到。事务还涉及读取文件系统的现有内容,这就对事务之间的顺序产生了约束。修改磁盘上的块的事务不能在读取该新数据并根据读取的内容更新磁盘的事务之后提交。即使这两个事务永远不会尝试将相同的块写回,依赖关系仍然存在——想象一下一个事务从目录中的一个块中删除一个文件名,另一个事务将相同的文件名插入到不同的块中。这两个操作可能不会在它们写入的块中重叠,但是第二个操作仅在第一个操作成功后才有效(违反此规则会导致重复的目录条目)。最后,还有一个超出元数据更新顺序的排序要求。在我们可以提交为文件分配新块的事务之前,我们必须确保该事务创建的所有数据块实际上已经被写入磁盘(我们将这些数据块称为依赖数据)。忽略此要求实际上不会损害文件系统的元数据的完整性,但可能导致在崩溃恢复后新文件仍然包含之前的文件内容,这既是安全风险也是一致性问题。Merging transactions许多日志记录文件系统中使用的术语和技术都来自数据库领域,在那里,日志记录是确保复杂事务的原子提交的标准机制。然而,传统数据库事务与文件系统之间存在许多差异,其中一些差异使我们能够大大简化事务处理。最大的两个差异之一是文件系统没有事务中止,而且所有文件系统事务都相对短暂。在数据库中,有时我们希望在事务进行到一半时中止事务,放弃我们到目前为止所做的任何更改,但是在ext2fs中并非如此——在我们开始对文件系统进行任何更改之前,我们已经检查了更改是否可以合法完成。在开始写入更改之前中止事务(例如,如果创建文件操作发现同名文件已存在,则可能中止事务)并不会造成问题,因为在这种情况下,我们可以简单地提交没有更改的事务并达到相同的效果。第二个差异——文件系统事务的短生命周期——很重要,因为它意味着我们可以极大地简化事务之间的依赖关系。如果我们必须处理一些非常长期的事务,那么我们需要允许事务以任何顺序独立提交,只要它们不互相冲突,否则单个搁置的事务可能会拖慢整个系统。然而,如果所有事务都足够快,则我们可以要求事务以严格的顺序顺序提交到磁盘,而不会显著影响性能。有了这个观察结果,我们可以对事务模型进行简化,从而大大减少实现的复杂性,同时提高性能。与其为每个文件系统更新创建一个单独的事务,我们简单地每隔一段时间创建一个新事务,并允许所有文件系统服务调用将它们的更新添加到该单一的系统级合并事务中。这种机制有一个很大的优点。因为合并事务中的所有操作将一起提交到日志中,所以我们不必为非常频繁更新的任何元数据块编写单独的副本。特别是对于创建新文件等操作,通常每次对文件的写入都会导致文件被扩展,从而连续更新相同的配额、位图块和inode块。在合并事务的生命周期内多次更新的任何块只需要提交一次到磁盘即可。关于何时提交当前合并事务并启动新事务的决定是一项策略决策,应该由用户控制,因为它涉及影响系统性能的权衡。提交等待时间越长,日志中就可以合并更多的文件系统操作,因此长期来说需要的IO操作就越少。然而,较长的提交会占用更多的内存和磁盘空间,并且在发生崩溃时留下更大的丢失更新的时间窗口。它们还可能导致磁盘活动风暴,使文件系统的响应时间不太可预测。On-disk representation日志记录的ext2fs文件系统在磁盘上的布局将完全与现有的ext2fs内核兼容。传统的UNIX文件系统通过将每个文件与磁盘上的唯一编号的inode关联来存储数据,并且ext2fs设计已经包含了一些预留的inode编号。我们使用其中一个保留的inode来存储文件系统的日志,除此之外,在所有其他方面,文件系统将与现有的Linux内核兼容。现有的ext2fs设计包括一组兼容性位图,其中的位可以设置为指示文件系统使用某些扩展功能。通过为日志记录扩展功能分配一个新的兼容性位,我们可以确保即使旧内核能够成功挂载新的、带有日志记录的ext2fs文件系统,它们也将被禁止以任何方式对文件系统进行写入。Format of the filesystem journal日志文件的工作很简单:在我们提交事务的过程中,它记录文件系统元数据块的新内容。日志的唯一其他要求是我们必须能够以原子方式提交其中包含的事务。我们向日志中写入三种不同类型的数据块:元数据块、描述符块和头部块。日志元数据块包含一个事务更新后文件系统元数据单个块的完整内容。这意味着无论我们对文件系统元数据块进行多么小的更改,我们都必须写入一个完整的日志块以记录更改。然而,出于以下两个原因,这实际上相对廉价:日志写入通常很快,因为大多数写入日志的操作都是顺序的,并且我们可以轻松地将日志IO批处理成大型簇,以便磁盘控制器有效处理;通过将来自文件系统缓存的更改的整个元数据缓冲区的内容写入日志,我们避免了在日志记录代码中进行大量的CPU工作。Linux内核已经为我们提供了一种非常高效的机制,用于将缓冲区缓存中现有块的内容写入到磁盘的其他位置。每个缓冲区在缓冲区缓存中都由一个称为buffer_head的结构描述,其中包括有关将缓冲区的数据写入的磁盘块的信息。如果我们想要将整个缓冲区块写入到新位置而不干扰buffer_head,我们只需创建一个新的临时buffer_head,将描述从旧缓冲区块复制到其中,然后编辑临时buffer_head中的设备块编号字段,以指向日志文件内的一个块。然后,我们可以直接将临时buffer_head提交给设备IO系统,并在IO完成后丢弃它。描述符块是描述其他日志元数据块的日志块。每当我们要将元数据块写入日志时,我们都需要记录元数据通常位于的磁盘块,以便恢复机制可以将元数据复制回主文件系统。在日志中的每组元数据块之前都会写入一个描述符块,其中包含要写入的元数据块数以及它们的磁盘块编号。描述符块和元数据块都按顺序写入日志,每当我们到达日志末尾时,就重新从日志开头开始写入。始终保持日志的当前头(最后一个写入的块的块编号)和尾部(日志中未被取消固定的最旧块,如下所述)。每当我们用完日志空间时(即日志的头已经循环回到尾部并赶上尾部),我们会暂停新的日志写入,直到日志的尾部已经清理并释放更多空间。最后,日志文件包含一些位于固定位置的头部块。这些记录了日志的当前头和尾部,以及一个序列号。在恢复时,将扫描头部块以找到具有最高序列号的块,并且在恢复过程中扫描日志时,我们只需从记录在该头部块中的尾部到头部的所有日志块。Committing and checkpointing the journal在某个时刻,要么是因为距上次提交已经等待了足够长的时间,要么是因为日志空间不足,我们将希望将未处理的文件系统更新作为一个新的复合事务提交到日志中。一旦复合事务完全提交,我们还没有完成它。我们需要跟踪记录在事务中记录的元数据缓冲区,以便我们可以注意到它们何时被写回到磁盘上的主要位置。回想一下,当我们提交一个事务时,新的更新后的文件系统块位于日志中,但尚未同步回它们在磁盘上的永久位置(我们需要保持旧块未同步,以防在提交日志之前崩溃)。一旦日志已提交,磁盘上的旧版本就不再重要了,我们可以在自己方便的时候将缓冲区写回其主位置。然而,在我们完成同步这些缓冲区之前,我们不能删除日志中的数据副本。要完全提交和完成事务的检查点,我们需要经历以下阶段:关闭事务。此时,我们创建一个新的事务,在其中记录将来开始的任何文件系统操作。任何现有的、未完成的操作仍将使用现有的事务:我们不能将单个文件系统操作分割成多个事务!开始将事务刷新到磁盘。在单独的日志写入内核线程的上下文中,我们开始写入由事务修改的所有元数据缓冲区到日志中。在这个阶段,我们还必须写入任何相关的数据(参见上文中的事务解剖)。当一个缓冲区被提交时,将其标记为固定在事务中,直到它不再是脏的(它已通过常规的写回机制写回到主存储中)。等待此事务中所有未完成的文件系统操作完成。我们可以在所有操作完成之前安全地开始写入日志,并且允许这两个步骤在某种程度上重叠会更快。等待所有未完成的事务更新完全记录在日志中。更新日志头块以记录日志的新头和尾部,将事务提交到磁盘。写入事务的更新缓冲区到日志时,我们标记它们为在日志中固定该事务。只有当缓冲区被同步到其磁盘上的主位置时,它们才会变为未固定。只有当事务的最后一个缓冲区变为未固定时,我们才能重新使用事务占用的日志块。当发生这种情况时,再次写入一组日志头,记录日志尾的新位置。现在释放的空间可以被稍后的事务重用。Collisions between transactions为了提高性能,在提交事务时,我们并不完全暂停文件系统更新。相反,我们创建一个新的复合事务,用于记录在提交旧事务时到达的更新。这就引出了一个问题:如果一个更新要访问已经由另一个正在提交的较旧事务拥有的元数据缓冲区,该怎么办呢?为了提交旧事务,我们需要将其缓冲区写入日志,但我们不能在该写入中包含任何不属于该事务的更改,因为这将允许我们提交不完整的更新。如果新事务只想读取相关缓冲区,那么就没有问题:我们已经在两个事务之间创建了一个读/写依赖关系,但由于复合事务始终按严格的顺序提交,我们可以安全地忽略这种冲突。如果新事务想要写入缓冲区,则情况就更复杂了。我们需要旧缓冲区的副本来提交第一个事务,但我们又不能让新事务在修改缓冲区时继续进行。解决方案是在这些情况下制作缓冲区的新副本。一个副本提供给新事务进行修改。另一个副本仍由旧事务拥有,并将像往常一样提交到日志中。一旦该事务提交,这个副本就会被简单地删除。当然,我们不能在这个缓冲区安全地记录到文件系统其他位置之前回收旧事务的日志空间,但由于该缓冲区必须被提交到下一个事务的日志记录中,这是自动处理的。Project status and future work这仍然是一个正在进行的工作。最初实现的设计稳定且简单,我们不认为需要进行重大的设计修订才能完成实现。上文描述的设计相对直接,除了处理日志文件管理、缓冲区与事务之间的关联以及在非正常关闭后恢复文件系统的代码外,对现有的 ext2fs 代码需要进行的修改很少。一旦我们有一个稳定的代码库进行测试,我们可以在许多可能的方向上扩展基本设计。其中最重要的是调整文件系统性能。这将需要我们研究日志系统中的任意参数对性能的影响,如提交频率和日志大小。这还将涉及研究瓶颈,以确定通过对系统设计进行修改是否可能改进性能,并且已经提出了几个可能的扩展设计。一个研究领域可能是考虑压缩日志更新。当前的方案要求我们将整个元数据块写入日志,即使只修改了块中的一个位也是如此。我们可以通过仅记录缓冲区中的更改值而不是完全记录缓冲区来轻松压缩这样的更新。然而,目前尚不清楚这是否会带来任何重大的性能优势。当前的方案对于大多数写操作不需要进行内存到内存的复制,这在CPU和总线利用率方面是一个很大的性能优势。由于写出整个缓冲区产生的IO操作是廉价的 - 更新是连续的,在现代磁盘IO系统上,它们直接从主存储器传输到磁盘控制器,而无需通过缓存或CPU。另一个重要的可能的扩展领域是支持快速 NFS 服务器。NFS 设计允许客户端在服务器崩溃时优雅地恢复:客户端将在服务器重新启动时重新连接。如果发生这样的崩溃,则服务器尚未安全写入磁盘的任何客户端数据都将丢失,因此 NFS 要求服务器在将客户端的文件系统请求提交到服务器的磁盘之前不得确认其完成。这对于支持通用目的的文件系统来说可能是一个尴尬的特性。NFS 服务器的性能通常是通过对客户端请求的响应时间来衡量的,如果这些响应必须等待文件系统更新同步到磁盘,那么整体性能将受到在磁盘上的文件系统更新延迟的限制。这与文件系统的大多数其他用途相反,在这些用途中,性能是根据缓存更新的延迟而不是磁盘上的更新延迟来衡量的。有一些文件系统专门设计来解决这个问题。WAFL 是一种基于事务的树型文件系统,可以在磁盘的任何位置写入更新,但是 Calaveras 文件系统通过使用类似于上文提出的日志的方式达到相同的目标。区别在于 Calaveras 为每个应用程序文件系统请求在日志中记录一个单独的事务,从而尽快在磁盘上完成单个更新。提议的 ext2fs 日志记录中的提交批处理牺牲了快速提交以获得吞吐量,而代价是延迟(磁盘上的延迟被缓存的影响隐藏了)。ext2fs 日志记录可能使其更适合用于 NFS 服务器的两种方法可能是使用较小的事务和记录文件数据以及元数据。通过调整提交到日志的事务的大小,我们可能能够大幅改进提交单个更新的回转时间。NFS 还要求将数据写入磁盘尽快提交,原则上没有理由不扩展日志文件以涵盖普通文件数据的写入。最后,值得注意的是,这个方案中没有任何阻止我们将单个日志文件共享给几个不同的文件系统。在一个单独的完全为此目的保留的磁盘上允许多个文件系统记录日志几乎不需要额外的工作,而在有许多经历重负的日志文件系统的情况下,这可能会带来显著的性能提升。这个独立的日志磁盘几乎完全是顺序写入的,因此可以维持高吞吐量,而不会影响主文件系统磁盘上可用的带宽。Conclusions本文中概述的文件系统设计应该相对现有的 Linux 上的 ext2fs 文件系统提供显著优势。它应该通过使文件系统在崩溃后更可预测地和更快速地恢复来提高可用性和可靠性,并且在正常操作期间不会造成太大的性能损失。日常性能的最显著影响是,新创建的文件将需要迅速同步到磁盘,以便将创建操作提交到日志,而不是允许内核通常支持的数据延迟写回。这可能使得日志文件系统不适合在 /tmp 文件系统上使用。该设计应该对现有的 ext2fs 代码库需要进行最少的更改:大部分功能由一个新的日志机制提供,该机制将通过简单的事务缓冲区 IO 接口与主要的 ext2fs 代码进行交互。最后,这里提出的设计是基于现有的 ext2fs 磁盘文件系统布局的,因此可以向现有的 ext2fs 文件系统添加一个事务日志,利用新功能,而无需重新格式化文件系统。
2024年04月23日
0 阅读
0 评论
0 点赞
2024-03-22
How to start RBC CPP Practice
Github首先你需要注册一个github账号,再打开仓库链接,点击这里fork仓库。在这里点击Create fork接着回到github主界面,打开你刚才fork的仓库,像这里这样点绿色的Code复制链接这时候你需要一个linux环境,如果你有linux主机,那太好了,接下来这一步可以跳过,如果你没有linux环境那么可以安装VMWare,或者wsl,这里我用的是wsl,所以接下来我讲一下如何安装使用wsl,以及如何用你的VSCode连接上你的wsl(VM教程很多,随便都能找到,我电脑没VM所以这里不讲了)wsl打开你的powershell,输入下边的命令:wsl --install下载时候你会看到这样的界面下载成功后需要你配置用户名密码,自己记好,用户名别用中文。如果没有这步,先重启再继续输入上边的命令。下载成功后重启电脑,这样就下载好Ubuntu环境了,我们进入下一步,配置VSCode。默认的wsl是下载到C盘的,如果你不想放在C盘,那么可以看这个文章WSL的安装和位置迁移VSCode这里主要讲仓库的拉取,具体VSCode调试配置请看这篇文章VSCode配置大法首先打开你的VSC,在插件这里搜索remote,下载remote-ssh之后点击VSC的左下角><这个按钮你可以看到这个界面选择WSL,打开以后你进成功进入到wsl里的ubuntu虚拟机了,然后在终端下载你的代码库像这样可以看到RBC-CPP-Practice已经被拉取下来了,接着点击VSC左上角的文件>打开文件夹选择你RBC-CPP-Practice文件夹并确认这样所有准备工作都做完了,开始学习吧!运行test在进入到项目文件夹之后,输入一次mkdir build cd build cmake ..之后 Ctrl+Shift+p 搜索 reload window选择确认等你写完对应hpp想要测试自己实现的是否正确时就按照下图输入命令,这里以list_test为例可以看到有一些黄色的和绿色的,黄色的就是不需要你必须完成的,绿色的就是测试通过的,如果出现红色的,那么说明你的实现有问题。再改改吧。如果你想运行完整测试,那么就像这样,删除这里的DISABLED_再make一下test重新测试,全绿的话恭喜你,你通过了所有测试!你可以选择提交你的实现,注意提交时候不要修改到原本仓库的文件,仅提交你自己的实现,你的实现将会成为其他人的参考,如果你愿意最好加上足够详细的注释,方便其他人阅读注意你的WSL可能不能通过https链接拉取仓库代码,这时候你就需要通过ssh拉取了具体是在终端输入ssh-keygen -t rsa,然后一路回车,再cat ./.ssh/id_rsa.pub你会看到一串以 ssh-rsa 开头长得像乱码的东西,复制这一串东西打开 https://github.com/settings/keys点New SSH key,title随便起,把你复制的内容输入到下边的Key的大框里提交即可,然后在你fork的仓库界面选择ssh链接也就是第三张图的https旁边的那里的链接,其他步骤一样提交你的实现时一定要保证全绿通过才行
2024年03月22日
0 阅读
0 评论
0 点赞
2024-03-08
[Tools]VSCode配置大法
不出意外的话,大家最开始使用VSCode的时候都被它的配置麻烦到了吧,接下来我会尽量详细的介绍.vscode文件夹里的json文件都是干什么的,以及你需要怎么写文件介绍一般情况下你的./vscode文件夹里可能有这些文件: launch.json文件就是你的调试程序的配置文件(感觉表达不是很清楚)tasks.json里写的是一些调试前需要运行的一些命令settings.json里写的是VSC的插件的配置,这些配置仅在当前目录下生效c_cpp_properties.json相当于指路牌,告诉你的分析代码的插件应该上哪找头文件(仅限C/C++,我不了解其他语言有没有类似的文件)一般情况下,你可能只需要task以及launch,执行task编译生成程序再launch调试。这里就主要讲一下这俩tasks.json其实现在你只要把鼠标放到上边他就会提示你这是什么,但是大家可能还是不太懂啊,如果是你自己配置的话你需要注意"command"参数即可,你需要在这里输入你的编译器路径即可(如果你只需要编译的话,其他的操作同理,输入对应程序的路径,别说你不知道g++,不会下载,不知道自己查去)。如果你需要修改编译参数,只需要在"args"里加新加一行你需要的参数,比如我的参数有"-std=c++23"代表我写的代码需要使用c++23的库,然后"-g"对应文件编译输出到"${fileDirname}/${fileBasenameNoExtension}.out"就是文件所在目录里,编译文件名叫 文件名.out。例如这段代码,经过task生成,使用对应参数最后输出编译文件launch.json{ "configurations": [ { "name": "C/C++: g++.exe 生成和调试活动文件", "type": "cppdbg", "request": "launch", "program": "${fileDirname}/${fileBasenameNoExtension}.out", "args": [], "stopAtEntry": false, "cwd": "${fileDirname}", "environment": [], "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/home/koarz/ldb_toolchain/bin/gdb", "setupCommands": [ { "description": "为 gdb 启用整齐打印", "text": "-enable-pretty-printing", "ignoreFailures": false }, { "description": "将反汇编风格设置为 Intel", "text": "-gdb-set disassembly-flavor intel", "ignoreFailures": true } ], "preLaunchTask": "C/C++: g++.exe 生成活动文件" } ] }为了方便你直接用,我就把我的配置贴到这。首先看"program"参数,这个参数就是你要调试的文件的位置,这里用 $ 那一串就是为了方便,你不嫌麻烦的话可以自己写文件的绝对位置,不然直接这个参数就照着tasks.json里的-o后边的写就行,args就是执行你调试程序输入的参数,一般来说你不需要写这个,除非你的程序需要处理某些参数,"externalConsole"为true的话就会直接跳出来程序界面,否则就在vsc终端那里显示了,"miDebuggerPath"后需要写你的gdb路径(如果你用gdb调试的话,用lldb就写lldb路径并将"MIMode"改成lldb)。最后就是"preLaunchTask",这个就是你在调试前执行的tasks中的task,具体内容填写tasks里的 "label" 就会执行对应task了,在执行完之后才会执行gdb调试。
2024年03月08日
0 阅读
0 评论
0 点赞
2024-01-11
[C++]通过智能指针的实现理解RAII
C以及C++的内存管理是让人很头疼的,指针满天飞,或者是忘了及时释放内存导致内存泄漏,多线程情况下加了锁忘记unlock...一些其他语言例如java有gc来防止内存泄漏,C++也有类似机制,那就是RAII:资源获取即初始化(Resource Acquisition Is Initialization)关于RAII的具体介绍可以看cppreference,简单来讲就是将需要管理的资源与对象的生命周期绑定,当对象析构时可以自动(通过析构函数)释放所管理的资源。RAII应用的典型就是智能指针了,接下来我会讲讲智能指针的实现以及原理,让你理解这一工具(可以叫工具吗?我不知道){dotted startColor="#ff6c6c" endColor="#1989fa"/}首先讲讲程序运行时的堆栈结构吧我们熟知的main函数以及其他各种函数都是在栈上运行的,具体结构类似这样: 在栈上储存着普通的变量,或者对象什么的,图中的情况就是类似这样一段代码运行时的情况void fun2() { int a{}; SomeClass b; ... } void fun1() { ... fun2(); ... } int main() { ... fun1(); ... }如果fun2执行完毕,那么图中fun1以下的地方都会被“移除”,也就是出栈了,接着继续执行fun1中的剩余代码。 于是我们可以知道,fun2内部的所有变量(a,b...)的生命周期都随着fun2执行结束而结束了,那么如果b就是一个std::unique_ptr,这时候b所管理的资源也就随着b的析构而被释放了。如果你没看懂的话我们可以将这个例子写得更具体一点。#include <iostream> using namespace std; class A { public: A() { cout << "A()\n"; } ~A() { cout << "~A()\n"; } }; void fun2() { cout << "fun2 begin\n"; A b; cout << "fun2 end\n"; } void fun1() { cout << "fun1 begin\n"; fun2(); cout << "fun1 end\n"; } int main() { cout << "main begin\n"; fun1(); cout << "main end\n"; return 0; }{card-default label="运行结果" width=""}main beginfun1 beginfun2 beginA()fun2 end~A()fun1 endmain end{/card-default}这样对象的生命周期是不是就很直观了,我们就可以理解std::unique_ptr在执行过程中做了什么。这里给出一个简单的unique_ptr的实现:#pragma once namespace koarz { template <typename T> class unique_ptr; } // namespace koarz template <typename T> class koarz::unique_ptr { private: T *ptr_; public: unique_ptr() : ptr_(nullptr){}; unique_ptr(T *ptr) : ptr_(ptr){}; unique_ptr(koarz::unique_ptr<T> &) = delete; unique_ptr &operator=(koarz::unique_ptr<T> &) = delete; unique_ptr(koarz::unique_ptr<T> &&) noexcept; unique_ptr &operator=(koarz::unique_ptr<T> &&) noexcept; T *operator->() const { return ptr_; } T &operator*() const { return *ptr_; } T *get() const { return ptr_; } T *release() { T *tmp = ptr_; ptr_ = nullptr; return tmp; } void reset(T *ptr = nullptr) { if (ptr_ != ptr) { delete ptr_; } ptr_ = ptr; } ~unique_ptr() { delete ptr_; } }; template <typename T> koarz::unique_ptr<T>::unique_ptr(koarz::unique_ptr<T> &&other) noexcept { ptr_ = other.ptr_; other.ptr_ = nullptr; } template <typename T> koarz::unique_ptr<T> & koarz::unique_ptr<T>::operator=(koarz::unique_ptr<T> &&other) noexcept { if (ptr_ != other.ptr_) { delete ptr_; } ptr_ = other.ptr_; other.ptr_ = nullptr; return *this; } 可以看到在析构函数中呢,我们对unique_ptr管理的内存进行了释放,也就是在执行上边代码的~A的时候释放了这块内存,fun2就可以改成这样:void fun2() { koarz::unqiue_ptr<int> a(new int(10)); cout << *a << endl; } // 我并没有实现make_unique所以我这里直接new了,但是一般使用智能指针我们会使用 // unique_ptr<int> a = make_unique<int>(10);智能指针的资源不应该在构造期间构造这段代码肯定会输出10然后析构掉new出来的内存,这就是智能指针管理内存的方式也就是利用RAII技术完成资源管理的一个例子。RAII的应用有很多std::unique_ptr 及 std::shared_ptr 用于管理动态分配的内存,或以用户提供的删除器管理任何以普通指针表示的资源;std::lock_guard、std::unique_lock、std::shared_lock 用于管理互斥体。最后再贴上shared_ptr的简单实现,你可以试着理解shared_ptr与unique_ptr的不同在哪。#pragma once #include <atomic> namespace koarz { template <typename T> class shared_ptr; } // namespace koarz template <typename T> class koarz::shared_ptr { private: T *ptr_{nullptr}; std::atomic_uint *count_{nullptr}; void release() { if (count_ != nullptr) { (*count_)--; if (*count_ == 0) { delete count_; delete ptr_; count_ = nullptr; ptr_ = nullptr; } } } public: shared_ptr() : ptr_(nullptr), count_(nullptr){}; shared_ptr(T *ptr) : ptr_(ptr) { count_ = new std::atomic_uint(1); }; shared_ptr(const koarz::shared_ptr<T> &) noexcept; shared_ptr &operator=(const koarz::shared_ptr<T> &) noexcept; shared_ptr(koarz::shared_ptr<T> &&) noexcept; shared_ptr &operator=(koarz::shared_ptr<T> &&) noexcept; T *operator->() const { return ptr_; } T &operator*() const { return *ptr_; } T *get() const { return ptr_; } void reset(T *ptr = nullptr) { if (ptr == ptr_ || ptr == nullptr) return; ptr_ = ptr; if (count_ != nullptr) { *count_ = 1; } else { count_ = new std::atomic_uint(1); } } void swap(koarz::shared_ptr<T> &); int get_count() { return *count_; } ~shared_ptr(); }; template <typename T> koarz::shared_ptr<T>::shared_ptr(const koarz::shared_ptr<T> &other) noexcept { if (other.ptr_ != this->ptr_) { release(); ptr_ = other.ptr_; count_ = other.count_; (*count_)++; } } template <typename T> koarz::shared_ptr<T> & koarz::shared_ptr<T>::operator=(const koarz::shared_ptr<T> &other) noexcept { if (other.ptr_ != this->ptr_) { release(); ptr_ = other.ptr_; count_ = other.count_; (*count_)++; } return *this; } template <typename T> koarz::shared_ptr<T>::shared_ptr(koarz::shared_ptr<T> &&other) noexcept { if (other.ptr_ != this->ptr_) { release(); ptr_ = other.ptr_; count_ = other.count_; } } template <typename T> koarz::shared_ptr<T> & koarz::shared_ptr<T>::operator=(koarz::shared_ptr<T> &&other) noexcept { if (other.ptr_ != this->ptr_) { release(); ptr_ = other.ptr_; count_ = other.count_; } return *this; } template <typename T> koarz::shared_ptr<T>::~shared_ptr() { if (count_ != nullptr) { (*count_)--; if (*count_ == 0) { delete ptr_; delete count_; } } } // 这段代码当然没有完整实现,你可以试着让它更加完善shared_ptr只有在引用计数为0的情况下才会delete掉管理的内存,而拷贝时会增加引用计数,一般情况下shared_ptr对象析构时只会减少引用计数,这样就保证了最后一个共享这块内存的对象析构时才会释放这块内存。
2024年01月11日
0 阅读
0 评论
0 点赞
1
2
3
...
5