图片加载库的缓存直接决定用户感知:命中就瞬间上屏,没命中就闪一下占位图再切换。ImageKnifePro 的缓存要同时处理多线程并发读写、内存字节级上限管理、磁盘文件的并发保护三件事,整套实现在 C++ 层用 lru11 加 std::mutex 完成。

一、内存缓存的数据结构

MemoryCache 是一个 Meyer's Singleton,核心数据结构声明如下:

lru11::Cache<std::string, std::shared_ptr<ImageDataCache>, std::mutex> cache_;

lru11 是一个 header-only 的 C++11 LRU 库,内部用 std::list 维护访问顺序,用 std::unordered_map 做 O(1) 查找。每次 tryGet 命中时,对应节点从链表中摘出、移到头部,表示"最近被使用过"。淘汰时从链表尾部取,尾部就是最久没被访问的条目。

第三个模板参数决定锁类型。传 std::mutex 让 lru11 在 insert/remove/tryGet 等操作内部自动加互斥锁;传 NullLock 则无锁。选 std::mutex 的原因是图片解码线程写缓存、UI 线程读缓存,这两个方向必须互斥。

Get 方法用的是 tryGet 而不是 getget() 在 key 不存在时抛 KeyNotFound 异常,而缓存未命中是高频场景,用异常控制流开销太大。tryGet 命中返回 true 并刷新访问顺序,未命中直接返回 false。

LRU 数据结构

二、并发安全与内存上限

内存统计用 std::atomic<long> currentMemory_ 追踪。Put 方法的实现值得细看:

void MemoryCache::Put(const std::string &key, std::shared_ptr<ImageDataCache> value)
{
    cache_.insert(key, value, true);  // 第三个参数 true:跳过 lru11 内置 prune
    currentMemory_ += value->GetImageCacheSize();

    while (currentMemory_ >= allowMaxMemory_ || cache_.size() >= allowMaxSize_) {
        std::string keyOut;
        std::shared_ptr<ImageDataCache> valueOut;
        if (!cache_.getTail(keyOut, valueOut)) {
            break;
        }
        cache_.remove(keyOut);
        currentMemory_ -= valueOut->GetImageCacheSize();
        if (currentMemory_ < 0) {
            currentMemory_ = 0;
        }
    }
}

insert 的第三个参数传了 true,跳过 lru11 自带的 prune 逻辑。原因很直接:lru11 在自动 prune 时只从内部数据结构中删除条目,不会通知外部扣减 currentMemory_。如果让它自动淘汰,currentMemory_ 会只增不减,和真实占用脱节。

手动 prune 循环用 getTail 取链表尾部,remove 后在外部扣减字节数。currentMemory_ < 0 的兜底判断属于防御性编程——atomic 操作在高并发下可能出现 remove 和 Put 交错执行的情况,负数归零避免累积误差。

默认容量是 256 条、128MB。SetCacheLimit 可以调整,但做了硬上限:条目数最大 4096,内存最大 1GB。超过的入参直接截断到上限值。调整后调 cache_.resize(allowMaxSize_) 通知 lru11 更新容量参数,但 resize 不会立即淘汰多出来的条目——淘汰推迟到下一次 Put 时自然发生。可以选择立即淘汰,但那会在调整容量的调用点引入不确定的阻塞时间,"推迟到写入时"让淘汰成本分摊到后续每次写入。

三、文件缓存的 FileDesc 状态机

文件缓存 FileCache 的 LRU 中,value 存放的是一个 shared_ptr<FileDesc> 智能指针,用于描述文件状态,文件数据留在磁盘上。FileDesc 里嵌了一个 atomic<FileState>

enum class FileState { IDLE, READING, DELETING, WRITING };

struct FileDesc {
    size_t size = 0;
    std::atomic<FileState> state = FileState::IDLE;
    std::string extension;
};

四种状态围绕一个核心目标:不要删正在被读的文件。

FileDesc 状态机

写入时(SaveImageToDisk),FileDesc 创建即置为 WRITING,文件成功落盘后变为 IDLE,失败则从 cache 中 remove 掉这个 key。读取时(GetFileDescForRead),先检查当前 state,如果是 DELETINGWRITING 就返回 nullptr 表示缓存不可用,否则置为 READING,读完后归位 IDLE

std::shared_ptr<FileCache::FileDesc> FileCache::GetFileDescForRead(const std::string &fileKey)
{
    auto fileDesc = Get(fileKey);
    if (fileDesc == nullptr) {
        return nullptr;
    }
    if (fileDesc->state == FileState::DELETING || fileDesc->state == FileState::WRITING) {
        return nullptr;
    }
    fileDesc->state = FileState::READING;
    return fileDesc;
}

淘汰逻辑在 Put 内部的 while 循环里:

if (valueOut->state != FileState::READING) {
    valueOut->state = FileState::DELETING;
    cache_.remove(keyOut);
    DeleteFile(keyOut);
    RemoveMemorySize(valueOut->size);
} else {
    break;
}

尾部条目如果正在被读取,直接 break 放弃本轮淘汰。这看起来会让缓存暂时超限,但下一次 Put 会再次尝试,到那时读操作大概率已经结束。这个权衡是"宁可短暂超限,不要中断读操作"。每个 FileDesc 的 state 是独立的 atomic 变量,不同文件的读写互不阻塞,只有同一个文件的并发操作才通过状态检查来协调,比一把大锁锁住整个文件缓存并发度高得多。

四、大端缓存和小端缓存

默认的 FileCache 实例称为"大端缓存",存主流程的图片。很多应用里,头像和商品大图的访问模式完全不同:头像数量多、体积小、更新少;商品大图数量也多、体积大、更新频繁。如果它们共用一个 LRU,商品图的高频更新会把头像挤出缓存,用户每次进个人页都要重新加载。

FileCacheInterceptorDefault 内部用 unordered_map<string, FileCache*> 管理所有小端实例。addSmallEndFileCache 创建时做了三重校验:cacheName 不能为空(空字符串保留给大端);filePath 不能与大端目录相同(防止文件名冲突);不能和已有小端重名或同路径。三个校验分别返回 CACHE_NAME_EMPTYFILE_PATH_OCCUPIEDCACHE_NAME_OCCUPIED 错误码。

使用时在 ImageKnifeOption 中设置 fileCacheName 字段,拦截器在处理请求时检查这个字段,找到对应的小端实例就用它替代大端做读写。

大端小端架构

小端缓存的默认上限比大端低一半——条目数 2048 vs 4096,磁盘容量 256MB vs 512MB。这个不对称设置是合理的:小端通常存放体积较小的图片(头像、图标),单个文件占用少,同样的条目数下总容量不需要那么大。

所有 FileCache 实例的磁盘占用都计入一个全局的 static std::atomic<size_t> g_deviceDiskUsage,硬上限 1.5GB。每次写入时 AddMemorySize 同时累加实例级的 currentMemory_ 和全局计数器。淘汰时 RemoveMemorySize 同时扣减两者。g_deviceDiskUsagesize_t(无符号类型),扣减前要判断是否小于待扣减值,否则减出来会溢出成一个超大正数。

void FileCache::RemoveMemorySize(size_t value)
{
    currentMemory_ -= value;
    if (currentMemory_ < 0) {
        currentMemory_ = 0;
    }
    if (g_deviceDiskUsage < value) {
        g_deviceDiskUsage = 0;
    } else {
        g_deviceDiskUsage -= value;
    }
}

五、缓存键为什么用 SHA256

文件缓存的 key 由 DefaultCacheKeyGenerator::GenerateFileKey 生成。它把 loadSrc(URL 或本地路径)加上可选的 signature 拼接后做一次 SHA256 哈希。

std::string DefaultCacheKeyGenerator::GenerateFileKey(const ImageSource *imageSrc,
    const std::string &signature)
{
    std::ostringstream src;
    std::string fileKey;
    imageSrc->GetString(fileKey);
    src << "loadSrc==" << fileKey << ";";
    if (!signature.empty()) {
        src << "signature=" << signature << ";";
    }
    DoMd5Hash(src.str(), fileName);
    return fileName;
}

URL 里可能包含 /?&= 等字符,这些在大多数文件系统中要么非法要么有特殊含义。URL 长度也没有上限,而文件名通常限制在 255 字节。SHA256 哈希固定 64 个十六进制字符,既安全又不会超长。

内存缓存的 key 策略完全不同——它是明文拼接,包含 loadSrc、transformation、降采样参数、orientation、dynamicRange 等信息。同一张图经过不同变换会产生不同的内存 key,各自独立缓存。文件缓存 key 不含 transformation,因为磁盘存的是原始数据,变换在解码后执行。

CacheKeyGenerator 是一个虚基类,可以通过 SetCacheKeyGenerator 替换默认实现。如果业务的 URL 里带了时间戳或 token 参数,每次请求 URL 都不同,就需要自定义 key 生成逻辑把这些参数剥掉,否则同一张图会被当成不同的缓存条目反复下载。

六、四种写入策略

ImageKnifeOption 上有一个 writeCacheStrategy 字段,四种取值对应不同的业务场景。

DEFAULT 同时写内存和文件缓存,绝大多数场景用这个。MEMORY 只写内存缓存,适合验证码、一次性广告弹窗——这类图片下次启动不会再用,没必要占磁盘。FILE 只写文件缓存不写内存缓存,用于预加载场景——提前把图片下载到磁盘但不占用内存,等用户真正浏览到的时候再从磁盘读取解码。NONE 完全不缓存,适合监控画面、实时地图瓦片等内容持续变化的场景。

if (option->writeCacheStrategy == CacheStrategy::FILE ||
    option->writeCacheStrategy == CacheStrategy::NONE) {
    writeMemoryCache = false;
}
if (option->writeCacheStrategy == CacheStrategy::MEMORY ||
    option->writeCacheStrategy == CacheStrategy::NONE) {
    writeFileCache = false;
}

七、冷启动时的文件缓存恢复

冷启动时内存缓存是空的,文件缓存需要从磁盘恢复。InitFileCacheopendir/readdir 遍历缓存目录,对每个文件调 stat 获取 st_atimest_size。按 st_atime 降序排列后依次插入 LRU,最近访问的排在链表热端。如果累计容量超限,后面的文件直接删除。

std::sort(filesVector.begin(), filesVector.end(), [](const FileInfo &a, const FileInfo &b) {
    return a.lastAccessTime > b.lastAccessTime;
});
for (const auto &fileInfo : filesVector) {
    if ((currentMemory_ + fileInfo.fileSize) >= allowMaxMemory_ || (cache_.size() + 1) >= allowMaxSize_) {
        DeleteFile(fileInfo.fileName);
    } else {
        cache_.insert(fileInfo.fileName, std::make_shared<FileDesc>(fileInfo.fileSize, fileInfo.extension));
        currentMemory_ += fileInfo.fileSize;
    }
}
g_deviceDiskUsage += currentMemory_;
isFileInitComplete_ = true;

初始化完成前所有读写请求都会被 isFileInitComplete_ 标志位拦住,返回 nullptr。这个保护避免了"LRU 还没建好就查询"导致的假性未命中——明明文件在磁盘上,但 LRU 里还没录入,查不到就走网络重新下载,白白浪费带宽。全局磁盘计数器 g_deviceDiskUsage 在初始化结束后一次性累加,不在遍历过程中逐文件计数,减少 atomic 操作次数。

以上就是本篇内容的所有了~有什么问题欢迎在评论区提出


项目地址:ImageKnifePro

标签: none

添加新评论