C++ I/O性能优化:从内核原理到工程实践
1. 项目概述:为什么I/O优化是C++性能的“最后堡垒”?
做C++开发十几年,我处理过无数性能瓶颈,从算法优化到内存管理,再到多线程并发。但每次项目进入深水区,最终卡住脖子的,往往不是CPU算力不够,而是I/O这堵墙。很多人觉得I/O优化空间小,因为大部分工作由操作系统完成,这种想法其实是个误区。恰恰因为I/O涉及用户态到内核态的切换、硬件设备的物理限制,它才是性能优化中最具挑战性、也最容易被忽视的环节。
《C++性能优化指南》这本书的第11章专门讲I/O优化,不是没有道理的。当你把CPU和内存的潜力榨干后,I/O就成了决定系统吞吐量和响应时间的最后一道关卡。无论是处理海量日志的文件系统、高并发的网络服务,还是需要频繁加载资源的游戏引擎,I/O操作的效率直接决定了用户体验的上限。我见过太多项目,算法精巧、内存零碎片,但就是被磁盘读写或网络延迟拖垮,最终功亏一篑。
这一章的核心,就是带你穿透操作系统的抽象层,理解从你调用fstream::read到数据真正从硬盘读出来的完整链条。只有看清了全貌,你才知道刀该往哪里磨。优化I/O,本质上是在优化两件事:减少不必要的等待和让每一次等待都更有价值。接下来,我们就从原理到实践,把这堵墙拆开看看。
2. 深入内核:一次文件读写到底发生了什么?
很多人写C++文件操作,以为就是open、read/write、close三连。但在操作系统眼里,这背后是一场涉及多个内核模块的精密协作。不理解这个过程,优化就无从谈起。
2.1 虚拟文件系统(VFS):统一的抽象层
Linux为了支持EXT4、NTFS、Btrfs等多种文件系统,引入了虚拟文件系统(VFS)作为中间层。你的程序发起的read调用,首先到达的是VFS,而不是具体的磁盘驱动。VFS定义了所有文件系统都必须实现的通用接口(file_operations结构体),里面包含了open、read、write、mmap等函数指针。
当你打开一个文件时,内核会创建一个file结构体(文件对象),它包含了文件的打开模式、当前读写位置等信息。这个file对象通过VFS连接到具体的文件系统实现。VFS的核心数据结构包括:
- 超级块(super_block):代表一个已挂载的文件系统,存储其全局信息(如块大小、总空间)。
- 目录项(dentry):缓存目录结构,将路径名(如
/home/user/data.txt)映射到对应的inode。 - 索引节点(inode):文件的唯一标识,存储元数据(权限、大小、时间戳)和指向数据块的指针。
- 地址空间(address_space):连接文件数据和页缓存(Page Cache)的桥梁。
注意:
dentry和inode是分开缓存的。多次访问同一路径时,dentry缓存能快速定位到inode,避免重复遍历目录树。这也是为什么realpath()或频繁的路径解析会成为性能热点。
2.2 Page Cache:内核的“读缓存”与“写缓冲”
这是I/O性能优化的核心战场。当你读取文件时,数据并非直接从磁盘进入你的程序缓冲区。内核会先检查请求的数据页是否已在Page Cache中。如果命中(Cache Hit),直接拷贝到用户空间,这次读取就是一次快速的内存操作。如果缺失(Cache Miss),则触发缺页异常,内核发起磁盘I/O,将数据读入Page Cache,再拷贝给用户程序。
写入操作更微妙。调用write()时,数据通常只是被复制到Page Cache中就返回了,此时并未落盘。这个页会被标记为“脏页”(Dirty Page)。内核有后台线程(如pdflush)定期或在特定条件下(如缓存不足时)将脏页写回磁盘。这就是**回写缓存(Write-back Caching)**机制。
// 你以为的write:数据直接落盘 std::ofstream file("data.bin", std::ios::binary); file.write(buffer, size); // 这里返回时,数据可能还在内存里! file.close(); // 甚至close()返回时,数据也可能还在内核缓存中实操心得:
close()不保证数据物理写入磁盘!如果需要强一致性(如数据库事务日志),必须使用fsync()或fdatasync()。但频繁调用fsync会严重拖慢性能,因为它会阻塞直到数据落盘。这需要根据业务对数据安全性和性能的要求做权衡。
2.3 昂贵的上下文切换:用户态与内核态
这是I/O操作的主要开销来源之一。你的程序运行在用户态,无权直接操作硬件。当调用read()时,CPU会执行一条特殊指令(如syscall)触发软中断,切换到内核态。内核替你完成磁盘寻址、DMA传输等工作,然后再切换回用户态。这个过程需要保存和恢复CPU寄存器、堆栈等上下文,通常需要数百到数千个CPU周期。
更糟糕的是,如果请求的数据不在Page Cache中,当前线程可能会被挂起,让出CPU,直到磁盘I/O完成。这引入了调度延迟。虽然现代操作系统使用异步I/O和中断机制来减少CPU空转,但上下文切换的开销始终存在。
// 一次简单的读取,背后可能发生多次上下文切换 char buf[4096]; int fd = open("file.txt", O_RDONLY); read(fd, buf, sizeof(buf)); // 用户态->内核态->(可能阻塞)->内核态->用户态 close(fd);3. 核心优化策略:从“次数”和“粒度”入手
理解了底层原理,优化思路就清晰了:减少进出内核的次数和让每次进出干更多的活。
3.1 策略一:批量处理,减少系统调用次数
最直接有效的优化。不要逐字节、逐行地读写。
反面案例:低效的逐行读取
std::ifstream file("large.log"); std::string line; while (std::getline(file, line)) { // 每次getline都可能触发一次read系统调用 process(line); }如果文件有100万行,这可能意味着100万次微小的read调用和上下文切换。
优化方案:批量读取
std::ifstream file("large.log", std::ios::binary | std::ios::ate); auto fileSize = file.tellg(); file.seekg(0); std::vector<char> buffer(fileSize); if (file.read(buffer.data(), fileSize)) { // 一次性读入内存,只需1-2次系统调用 processEntireBuffer(buffer.data(), fileSize); }如果文件太大无法一次性装入内存,可以采用固定大小的缓冲区进行块读取:
const size_t BUFFER_SIZE = 64 * 1024; // 64KB,通常是一个较好的平衡点 std::vector<char> buffer(BUFFER_SIZE); std::ifstream file("huge.bin", std::ios::binary); while (file.read(buffer.data(), buffer.size())) { size_t bytesRead = file.gcount(); processChunk(buffer.data(), bytesRead); }参数选择逻辑:为什么是64KB?这考虑了多个因素:1) 常见磁盘扇区大小为4KB,64KB是16个扇区的对齐读取,效率高;2) 它远小于典型CPU L2/L3缓存(几MB),避免缓存污染;3) 对于SSD,较大的连续读取能更好利用其并行性。你可以根据实际硬件和文件大小调整,32KB-256KB都是常见范围。
写入同理,避免频繁的flush()。std::endl不仅输出换行符,还会强制刷新缓冲区,在循环中使用是性能杀手。
// 糟糕的做法 for (const auto& item : items) { logFile << item << std::endl; // 每次循环都flush! } // 好的做法 for (const auto& item : items) { logFile << item << '\n'; // 只输出换行,缓冲区由内核管理 } // 程序结束或缓冲区满时自动flush,或手动在关键点调用一次flush()3.2 策略二:使用内存映射文件(mmap)
对于需要随机访问的大文件,mmap是神器。它将文件直接映射到进程的虚拟地址空间,让你像操作内存一样操作文件。
#include <sys/mman.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> int fd = open("large_data.bin", O_RDONLY); struct stat sb; fstat(fd, &sb); size_t fileSize = sb.st_size; // 将文件映射到内存 void* mapped = mmap(nullptr, fileSize, PROT_READ, MAP_PRIVATE, fd, 0); if (mapped == MAP_FAILED) { // 错误处理 close(fd); return; } // 现在可以像普通内存一样访问文件内容了 const char* data = static_cast<const char*>(mapped); processData(data, fileSize); // 使用完毕后解除映射 munmap(mapped, fileSize); close(fd);mmap的优势:
- 减少数据拷贝:传统
read需要数据从内核Page Cache拷贝到用户缓冲区。mmap让进程直接通过页表访问Page Cache,省去了一次拷贝(“零拷贝”的一种形式)。 - 简化编程模型:随机访问变得极其自然,可以用指针直接操作。
- 延迟加载:
mmap只是建立了映射关系,实际数据页在首次访问时才会通过缺页异常加载,适合稀疏访问大文件。
mmap的陷阱:
- 内存开销:映射区域计入进程的虚拟内存空间。映射一个远超物理内存的大文件要小心。
- 错误处理:访问映射区域外的地址会触发
SIGSEGV信号。 - 写时复制(COW):
MAP_PRIVATE模式下,修改不会写回文件,而是创建私有副本。需要共享修改时用MAP_SHARED。 - 同步:
msync()用于将修改写回磁盘,类似于fsync。
实操心得:对于只读的配置文件、资源文件,
mmap是绝佳选择。对于需要频繁小块写入的日志文件,传统的write可能更合适,因为mmap的脏页回写机制可能带来不可预知的延迟。
3.3 策略三:调整I/O大小与对齐
磁盘(包括SSD)以“块”为单位操作。不合理的I/O大小和不对齐的访问会导致“读放大”或“写放大”。
- I/O大小:远小于设备块大小(如4KB)的读写效率极低。如果你总是读写512字节,磁盘实际上还是读写一个4KB的块,浪费了带宽。尽量使读写大小是块大小的整数倍。
- 内存对齐:直接I/O(
O_DIRECT)或某些高级用法要求缓冲区内存地址对齐(通常为512字节或4KB边界)。使用posix_memalign或C++17的aligned_alloc来分配对齐的内存。 - 文件偏移对齐:从文件偏移量0、4K、8K...开始读写,有助于文件系统优化。
// 使用O_DIRECT进行对齐的直接I/O(绕过Page Cache,适用于自实现缓存的情况) int fd = open("data.bin", O_RDWR | O_DIRECT); // 需要对齐! const size_t ALIGNMENT = 4096; void* buffer = nullptr; posix_memalign(&buffer, ALIGNMENT, BUFFER_SIZE); // 分配对齐的内存 ssize_t bytesRead = read(fd, buffer, BUFFER_SIZE); // ... 处理数据 free(buffer); close(fd);警告:
O_DIRECT用起来很麻烦,对齐要求严格,且完全绕过了Page Cache。除非你非常清楚自己在做什么(比如在实现数据库引擎),否则优先使用缓冲I/O。
3.4 策略四:异步I/O与重叠I/O
当I/O成为瓶颈,且无法通过批量处理进一步优化时,让CPU在等待I/O时去做别的事。这就是异步I/O的核心思想。
Linux原生异步I/O(AIO): Linux提供了libaio库,但接口较为复杂,且对磁盘文件的支持 historically 比网络套接字要差。
更通用的多线程异步: 更常见的模式是使用生产者-消费者队列,将I/O操作卸载到专用线程。
#include <queue> #include <thread> #include <mutex> #include <condition_variable> class AsyncFileWriter { public: AsyncFileWriter(const std::string& filename) : stop_(false) { worker_ = std::thread(&AsyncFileWriter::writeLoop, this); file_.open(filename, std::ios::binary | std::ios::app); } ~AsyncFileWriter() { { std::lock_guard<std::mutex> lock(queue_mutex_); stop_ = true; } cv_.notify_all(); if (worker_.joinable()) worker_.join(); file_.close(); } void write(const std::string& data) { { std::lock_guard<std::mutex> lock(queue_mutex_); write_queue_.push(data); } cv_.notify_one(); } private: void writeLoop() { while (true) { std::string data; { std::unique_lock<std::mutex> lock(queue_mutex_); cv_.wait(lock, [this] { return stop_ || !write_queue_.empty(); }); if (stop_ && write_queue_.empty()) break; if (!write_queue_.empty()) { data = std::move(write_queue_.front()); write_queue_.pop(); } } if (!data.empty()) { file_.write(data.data(), data.size()); // 可以在这里批量flush,而不是每次写入都flush } } } std::ofstream file_; std::thread worker_; std::queue<std::string> write_queue_; std::mutex queue_mutex_; std::condition_variable cv_; bool stop_; };这个模式将耗时的文件写入放到后台线程,主线程只需将数据放入队列即可返回,极大地提高了响应性。注意队列大小需要限制,防止内存爆掉。
3.5 策略五:文件访问模式提示
告诉操作系统你打算怎么访问文件,它可能会进行预读或缓存优化。
POSIX_FADV_SEQUENTIAL:顺序访问。内核会启动更积极的预读。POSIX_FADV_RANDOM:随机访问。禁用预读,避免浪费缓存。POSIX_FADV_WILLNEED:提示数据很快会被用到,建议内核预加载。POSIX_FADV_DONTNEED:提示数据短期内不再需要,内核可以释放相关缓存。
int fd = open("sequential_log.txt", O_RDONLY); posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL); // 告诉内核我会顺序读 // ... 读取操作 close(fd);注意:这些只是“建议”(hints),内核不一定完全遵从,但通常对性能有积极影响,特别是在处理大文件时。
4. 实战:一个高性能日志组件的I/O设计
理论说再多,不如看一个真实案例。我们设计一个高性能的C++日志库,它需要满足:1) 极低的写入延迟;2) 高吞吐量,能承受每秒数十万条日志;3) 不阻塞主业务线程。
4.1 架构设计
我们采用双缓冲队列 + 专用I/O线程的架构。
- 前端:业务线程调用
LOG_INFO(...)时,日志消息被格式化到一个线程局部的内存缓冲区。 - 交换:当线程局部缓冲区满,或遇到强制刷新(如
LOG_FLUSH)时,将该缓冲区移入一个全局的无锁队列。 - 后端:一个专用的I/O线程从无锁队列中取出缓冲区,批量写入文件。
// 简化的核心数据结构 class LogBuffer { char data_[64 * 1024]; // 64KB每块 size_t used_{0}; public: bool append(const char* msg, size_t len) { /* ... */ } void reset() { used_ = 0; } const char* data() const { return data_; } size_t size() const { return used_; } }; // 无锁队列(简化版,实际可用boost::lockfree::spsc_queue或自己实现环形缓冲区) class LockFreeQueue { std::atomic<LogBuffer*> buffer_queue_[MAX_QUEUE_SIZE]; // ... 无锁的入队出队逻辑 }; class AsyncLogger { std::unique_ptr<std::thread> io_thread_; LockFreeQueue queue_; std::ofstream log_file_; std::atomic<bool> running_{true}; // 线程局部缓冲区,每个写线程独享,避免竞争 static thread_local LogBuffer tls_buffer_; void ioThreadFunc() { std::vector<LogBuffer*> write_batch; write_batch.reserve(32); // 一次批量写32个缓冲区 while (running_ || !queue_.empty()) { // 批量从队列中取出多个缓冲区 queue_.dequeueBulk(write_batch, 32); if (!write_batch.empty()) { // 合并写入:减少系统调用次数 for (auto* buf : write_batch) { log_file_.write(buf->data(), buf->size()); buf->reset(); // 重置缓冲区,可放入对象池复用 releaseBuffer(buf); // 归还到对象池 } write_batch.clear(); // 定时或定量刷新,而不是每次写入都flush static int writeCount = 0; if (++writeCount % 100 == 0) { log_file_.flush(); } } else { std::this_thread::sleep_for(std::chrono::milliseconds(1)); // 避免空转 } } // 退出前强制刷新 log_file_.flush(); } public: void log(const std::string& message) { if (!tls_buffer_.append(message.c_str(), message.size())) { // 缓冲区满,提交到全局队列 LogBuffer* fullBuffer = &tls_buffer_; queue_.enqueue(fullBuffer); // 无锁入队 // 从对象池获取新的缓冲区 tls_buffer_ = acquireBuffer(); tls_buffer_.append(message.c_str(), message.size()); // 重试 } } };4.2 关键优化点解析
- 线程局部存储(TLS):每个写线程有自己的缓冲区,完全无锁,这是高性能的关键。格式化日志消息是CPU密集型操作,TLS避免了全局锁竞争。
- 批量提交与批量写入:前端批量填满缓冲区(如64KB)才提交一次;后端一次从队列取出多个缓冲区(如32个)合并写入。这极大减少了线程间同步和系统调用的频率。
- 对象池:
LogBuffer对象被反复使用,避免频繁的new/delete导致内存碎片和分配开销。 - 延迟刷新:不是每次
write后都调用flush()或fsync(),而是积累一定量(如100次批量写)或定时(如每秒)刷新一次。这利用了内核的Page Cache缓冲,将多次小I/O合并成一次大I/O。 - 无锁队列:使用
std::atomic和环形缓冲区实现一个单生产者单消费者(SPSC)队列,确保I/O线程和业务线程之间的数据传输几乎没有竞争。
4.3 文件滚动与性能
当日志文件过大时,需要滚动(如按天或按大小切割)。切文件时要注意:
- 原子性重命名:先将当前文件
rename为备份文件(如app.log.20231027),然后重新创建app.log。rename操作在大多数文件系统上是原子的。 - 避免阻塞:切文件操作应在I/O线程内进行,不要阻塞业务线程。
- 文件描述符复用:不要频繁
open/close文件。可以在I/O线程内保持ofstream打开,滚动时直接关闭旧流,打开新流。
5. 高级主题:直接I/O、io_uring与性能权衡
当你已经把常规优化手段用尽,还可以考虑以下更底层的技术。
5.1 直接I/O(O_DIRECT)的适用场景
我们之前提到O_DIRECT绕过了Page Cache。这听起来是坏事,但在特定场景下是好事:
- 自实现缓存:数据库(如MySQL InnoDB)有自己更精细的缓存策略(Buffer Pool),使用内核Page Cache反而多余,且会引起“双缓存”问题。
- 大文件顺序读写:如果你要顺序读写一个远超内存大小的文件(如视频处理),Page Cache会被频繁换入换出,成为开销。直接I/O可能更高效。
- 避免缓存污染:某些数据读一次后很久不再用(如一次性数据导入),它们占着Page Cache会挤掉更重要的热数据。
使用O_DIRECT必须保证缓冲区和文件偏移都按设备块大小对齐(通常512字节或4K)。不对齐的读写会失败(EINVAL错误)。
5.2 Linux io_uring:异步I/O的终极形态
传统的Linux AIO(libaio)对文件支持有限,且接口笨重。io_uring是Linux 5.1引入的崭新异步I/O接口,旨在彻底解决这些问题。
io_uring的核心优势:
- 真正的异步:支持所有类型的I/O(文件、网络等)。
- 零拷贝:通过
IORING_OP_READ_FIXED等,可以在用户空间和内核之间共享缓冲区,进一步减少数据拷贝。 - 批处理提交与完成:可以一次性提交多个I/O请求,并一次性收割多个完成事件,系统调用开销极低。
- 轮询模式:可以完全绕过系统调用和中断,通过忙等待检查完成状态,实现极低延迟(适用于SPDK这类场景)。
一个简单的io_uring读文件示例(需要Linux内核 >= 5.1):
#include <liburing.h> #include <fcntl.h> #include <cstring> int main() { struct io_uring ring; io_uring_queue_init(32, &ring, 0); // 初始化uring,队列深度32 int fd = open("test.txt", O_RDONLY); char buffer[4096]; // 准备一个读请求 struct io_uring_sqe* sqe = io_uring_get_sqe(&ring); // 获取一个提交队列条目 io_uring_prep_read(sqe, fd, buffer, sizeof(buffer), 0); // 设置读操作 io_uring_sqe_set_data(sqe, buffer); // 设置用户数据,用于回调识别 // 提交请求(可以批量提交多个) io_uring_submit(&ring); // 等待完成 struct io_uring_cqe* cqe; io_uring_wait_cqe(&ring, &cqe); // 阻塞等待一个完成事件 // 或 io_uring_peek_cqe 非阻塞检查 if (cqe->res >= 0) { // 读取成功,cqe->res是读取的字节数 processData(buffer, cqe->res); } else { // 错误处理 } io_uring_cqe_seen(&ring, cqe); // 标记此完成事件已处理 io_uring_queue_exit(&ring); close(fd); return 0; }注意:io_uring API较为底层,C++社区有封装库(如
liburingpp、iouring)。在生产环境中使用前,务必充分测试其稳定性和与你的glibc版本、内核版本的兼容性。
5.3 性能监控与工具
优化离不开测量。以下工具可以帮助你定位I/O瓶颈:
- iostat:查看磁盘利用率、吞吐量、响应时间。关注
%util(设备繁忙程度)和await(平均I/O等待时间)。 - vmstat:查看系统级别的内存、I/O、CPU上下文切换(
cs列)情况。 - strace:跟踪进程的系统调用,看看你的程序到底发起了多少次
read/write/fsync。 - perf:Linux性能分析神器。
perf record -g -p <pid>可以记录调用栈,perf report查看热点。perf stat -d ./your_program可以查看程序运行期间的I/O次数、缓存命中率等。
- /proc文件系统:
/proc/<pid>/io可以看到特定进程的读写字符数。/proc/sys/vm/dirty_*系列参数控制脏页回写行为,有时调整它们能缓解突发的I/O压力。
6. 常见陷阱与排查技巧
即使理解了所有原理,实际编码中还是会踩坑。下面是我总结的一些常见问题和解决方法。
6.1 问题排查表
| 现象 | 可能原因 | 排查工具/方法 | 解决思路 |
|---|---|---|---|
| 磁盘利用率100%,但吞吐量很低 | 大量随机小I/O | iostat -x 1看rkB/s(读吞吐)和avgqu-sz(队列长度) | 尝试合并小I/O为顺序大I/O;检查是否用了O_DIRECT且未对齐 |
| 程序CPU占用不高,但很“卡” | I/O阻塞导致线程频繁切换 | vmstat 1看cs(上下文切换)和wa(I/O等待) | 使用异步I/O或增加I/O线程;检查是否有不必要的fsync |
write()返回成功,但断电后数据丢失 | 数据只写到了Page Cache,未落盘 | 检查代码中是否缺少fsync或fdatasync | 在关键数据写入后调用fsync;或使用O_SYNC标志打开文件(性能差) |
mmap访问大文件时出现SIGBUS | 访问了文件末尾之后的内存(文件被截断) | 检查文件是否被其他进程修改 | 使用ftruncate扩展文件后再映射;或捕获SIGBUS信号处理 |
| 多线程写同一文件顺序混乱 | 各线程独立调用write,内核不保证顺序 | 检查日志输出 | 使用单个写线程;或每个消息加时间戳,由消费方排序 |
| 内存使用持续增长 | 文件映射未释放;或Page Cache占用 | pmap -x <pid>;free -h看cached部分 | 及时munmap;考虑使用posix_fadvise(POSIX_FADV_DONTNEED)释放缓存 |
6.2 “缓冲区风暴”问题
这是我在一个高并发服务中遇到的真实问题。每个请求都创建一个std::stringstream来格式化日志,导致短时间内分配大量小内存块,不仅造成内存碎片,还使得malloc成为瓶颈。
解决方案:使用线程局部的内存池或预分配的格式化缓冲区。
thread_local char format_buffer[2048]; // 每个线程预分配2KB int len = snprintf(format_buffer, sizeof(format_buffer), "Info: %s, value=%d", str, num); if (len > 0 && len < sizeof(format_buffer)) { logger.write(format_buffer, len); }对于更复杂的格式化,可以考虑使用fmt::format(C++20的std::format基础)这类高性能格式化库,它们内部通常有更好的缓冲区管理策略。
6.3 网络I/O的特别考量
虽然本章聚焦文件I/O,但网络I/O原理相通,优化点略有不同:
- Nagle算法与TCP_CORK:为了减少小包,默认启用Nagle算法,但可能增加延迟。对于需要低延迟的交互协议,考虑
TCP_NODELAY。对于需要批量发送的场景(如HTTP响应体),使用TCP_CORK。 - 缓冲区大小:适当调大TCP发送/接收缓冲区(
SO_SNDBUF,SO_RCVBUF)有助于提高吞吐量,特别是在高带宽延迟积(BDP)的网络中。 - 使用epoll/ kqueue / IOCP:对于高并发网络服务,使用I/O多路复用代替每连接一线程模型,这是另一个广阔的话题。
I/O优化没有银弹,它总是在时间、空间、复杂度、数据安全性之间做权衡。理解底层机制是做出正确权衡的前提。从批量处理减少系统调用,到使用mmap避免拷贝,再到引入异步和无锁设计,每一步都是在和硬件特性、操作系统行为做博弈。最好的优化,往往是那种让I/O子系统“顺其自然”工作的方式——用顺序访问代替随机访问,用大块传输代替零碎读写,用异步避免阻塞。当你把这些原则融入编码习惯,性能的提升会是水到渠成的事。
