Desafios e Soluções para Leitura Concorrente de Arquivos em C++

Ao lidar com operações de leitura e escrita de arqiuvos em ambientes multithreaded em C++, surgem desafios significativos, especialmente quando um processo de escrita adiciona continuamente novos dados a um arquivo enquanto outro processo tenta lê-lo. Uma abordagem ingênua, como um loop contínuo com fgets, pode se mostrar problemática.

Considere um cenário onde um thread é responsável por adicionar conteúdo a um arquivo e outro thread tenta ler esse arquivo. Se o thread de escrita for muito rápido, adicionando novos dados constantemente, um loop de leitura baseado em fgets pode não conseguir processar todo o conteúdo recém-adicionado de forma eficiente em uma única iteração de leitura. Embora fgets eventualmente alcance o fim do arquivo e retorne NULL, a lógica pode não ser ideal para um monitoramento contínuo e em tempo real de um arquivo em crescimento, pois exigiria mecanismos adicionais para reabrir o arquivo ou reposicionar o ponteiro para capturar os dados mais recentes.

Exemplo 1: Leitura Contínua com fgets (Abordagem Potencialmente Incompleta)

O exemplo a seguir ilustra essa situação. Um thread (threadEscritor) escreve linhas no arquivo "log.txt" a cada 150ms, enquanto outro thread (threadLeitor) tenta ler o arquivo usando fgets. O problema aqui não é necessariamente um loop infinito, mas sim que o leitor pode não conseguir processar *todo* o conteúdo dinamicamente adicionado pelo escritor, perdendo dados entre as sessões de leitura ou tendo uma visão desatualizada do arquivo.


#include <cstdio>
#include <thread>
#include <chrono> // Para std::chrono::milliseconds
#include <string> // Para std::string, embora não usado diretamente em C-style

// Função para simular a escrita de dados no arquivo
void threadEscritor() {
    FILE *logFile = fopen("log.txt", "a"); // Abre em modo append
    if (!logFile) {
        perror("Erro ao abrir log.txt para escrita");
        return;
    }
    for (int i = 0; i < 15; ++i) { // Número de iterações para demonstrar
        std::string prefixo = "DADO NOVO: ";
        std::string sufixo = " -- Item ID: ";
        printf("[ESCRITOR] %s%d%s%d\n", prefixo.c_str(), i, sufixo.c_str(), i * 100);
        fprintf(logFile, "%s%d%s%d\n", prefixo.c_str(), i, sufixo.c_str(), i * 100);
        fflush(logFile); // Garante que os dados são escritos no disco imediatamente
        std::this_thread::sleep_for(std::chrono::milliseconds(150)); // Simula um pequeno atraso
    }
    fclose(logFile);
}

// Função para simular a leitura de dados
void threadLeitor() {
    FILE *logFile = fopen("log.txt", "r"); // Abre em modo leitura
    if (!logFile) {
        perror("Erro ao abrir log.txt para leitura");
        return;
    }
    char bufferLinha[1024];
    printf("[LEITOR] Iniciando leitura do arquivo...\n");
    while (fgets(bufferLinha, sizeof(bufferLinha), logFile) != NULL) {
        printf("[LEITOR] Leu: %s", bufferLinha);
        std::this_thread::sleep_for(std::chrono::milliseconds(300)); // Simula processamento da linha lida
    }
    printf("[LEITOR] Fim da leitura (EOF ou erro).\n");
    fclose(logFile);
}

int main() {
    // Apaga o arquivo anterior se existir para um teste limpo
    remove("log.txt");

    // Inicia o leitor e dá um tempo para o escritor começar a gerar dados
    std::thread leitor(threadLeitor);
    std::this_thread::sleep_for(std::chrono::seconds(1)); // Pequeno atraso para o leitor "começar"

    std::thread escritor(threadEscritor);

    // Mantém o programa rodando tempo suficiente para ver a interação
    std::this_thread::sleep_for(std::chrono::seconds(10)); 

    // Garante que as threads terminem
    leitor.join();
    escritor.join();

    printf("Programa de exemplo 'fgets' finalizado.\n");
    return 0;
}

Neste exemplo, o thread leitor pode ler os dados que estavam presentes no início da sua execução e talvez alguns novos, mas a natureza de fgets de ler até o EOF significa que, uma vez que o EOF é atingido, o loop termina. Dados adicionados *após* esse ponto não serão vistos sem uma nova abertura de arquivo ou reposicionamento do ponteiro, o que não é o foco do loop while(fgets) tradicional.

Exemplo 2: Leitura por Snapshot (Abordagem Mais Robusta)

Uma abordagem mais robusta para ler um arquivo que está sendo ativamente modificado, especialmente quando se deseja processar todo o conteúdo atual em um determinado momento, é a leitura por "snapshot". Isso envolve reabrir o arquivo, determinar seu tamanho atual e ler todo o conteúdo disponível até aquele momento. É fundamental entender que cada chamada a fopen com um novo ponteiro de arquivo cria um contexto de arquivo independente. Seus ponteiros internos (como a posição atual de leitura) são independentes e não interferem em outras operações de arquivo que possam estar ocorrendo.


#include <cstdio>
#include <thread>
#include <chrono>
#include <vector> // Para std::vector para buffer dinâmico

// Função para simular a escrita de dados
void workerEscritor() {
    FILE *outputFile = fopen("data.txt", "a");
    if (!outputFile) {
        perror("Erro ao abrir data.txt para escrita");
        return;
    }
    for (int k = 0; k < 8; ++k) { // Número de iterações para o exemplo
        fprintf(outputFile, "Registro %d: Timestamp: %lld\n", k, (long long)std::chrono::duration_cast<std::chrono::seconds>(std::chrono::system_clock::now().time_since_epoch()).count());
        fflush(outputFile); // Força a escrita para o disco
        std::this_thread::sleep_for(std::chrono::milliseconds(600)); // Pequeno atraso
    }
    fclose(outputFile);
}

// Função para simular a leitura de dados por snapshot
void workerLeitor() {
    for (int k = 0; k < 6; ++k) { // Faz várias leituras de snapshot
        FILE *inputFile = fopen("data.txt", "rb"); // Abre em modo binário para obter tamanho exato
        if (!inputFile) {
            perror("Erro ao abrir data.txt para leitura");
            std::this_thread::sleep_for(std::chrono::milliseconds(800)); // Aguarda antes de tentar novamente
            continue;
        }

        fseek(inputFile, 0, SEEK_END);
        long fileSize = ftell(inputFile); // Obtém o tamanho atual do arquivo
        fseek(inputFile, 0, SEEK_SET); // Volta para o início do arquivo

        if (fileSize > 0) {
            std::vector<char> buffer(fileSize + 1); // Cria um buffer do tamanho exato + terminador nulo
            size_t bytesRead = fread(buffer.data(), 1, fileSize, inputFile);
            buffer[bytesRead] = '\0'; // Garante terminação nula no final dos bytes lidos
            printf("[LEITOR SNAPSHOT] Leitura %d (Bytes lidos: %zu de %ld):\n%s-- FIM DA LEITURA SNAPSHOT --\n", k, bytesRead, fileSize, buffer.data());
        } else {
            printf("[LEITOR SNAPSHOT] Leitura %d: Arquivo vazio ou erro.\n", k);
        }
        
        fclose(inputFile); // Fecha o arquivo
        std::this_thread::sleep_for(std::chrono::milliseconds(1000)); // Aguarda antes da próxima leitura
    }
}

int main() {
    // Apaga o arquivo anterior se existir
    remove("data.txt");

    std::thread leitorSnapshot(workerLeitor);
    std::this_thread::sleep_for(std::chrono::seconds(1)); // Pequeno atraso para o leitor começar
    std::thread escritor(workerEscritor);

    std::this_thread::sleep_for(std::chrono::seconds(15)); // Tempo para as threads interagirem

    leitorSnapshot.join();
    escritor.join();
    
    printf("Programa de snapshot finalizado.\n");
    return 0;
}

Considerações Importantes em Cenários de I/O Concorrente

Mesmo com abordagens como a leitura por snapshot, operações de I/O concorrente podem introduzir complexidades devido a como os sistemas operacionais e os sistemas de arquivos gerenciam dados. Algumas questões a serem consideradas incluem:

  • Buffer de Escrita: Dados escritos por um thread podem inicialmente residir em um buffer na memória e não serem imediatamente "persistidos" no disco. Isso significa que um thread leitor pode não ver as últimas modificações até que o buffer seja descarregado (flushed). O uso de fflush(), como mostrado nos exemplos, tenta mitigar isso forçando o descarregamento para o sistema operacional.
  • Cache do Sistema de Arquivos: O sistema operacional emprega caches para otimizar o desempenho de I/O. Um thread pode escrever dados que são armazenados em cache, e outro thread pode ler uma versão antiga do arquivo que está em outro cache, ou a versão do disco antes que o cache de escrita seja sincronizado. Isso pode levar a inconsistências temporárias.
  • Granularidade de Bloqueios de Arquivo: Se operações de escrita utilizam mecanismos de bloqueio (ex: flock, fcntl em sistemas POSIX), mas as leituras não, o leitor pode acessar o arquivo em um estado inconsistente ou enquanto ele está sendo modificado. Para garantir a consistência, tanto leitores quanto escritores podem precisar coordenar o acesso usando bloqueios ou outras primitivas de sincronização.
  • Problemas de Sincronização: O processo de escrita pode estar continuamente alterando o conteúdo do arquivo enquanto o leitor tenta acessar. Sem mecanismos de sincronização adequados, o leitor pode obter dados parciais, incompletos ou "rasgados", onde partes de uma nova escrita são vistas junto com partes de uma escrita antiga.

A escolha da estratégia de I/O concorrente depende dos requisitos específicos de consistência e desempenho da aplicação. Para arquivos de log ou streams de dados que são apenas anexados, a abordagem de snapshot pode ser suficiente, mas para arquivos que são modificados aleatoriamente, mecanismos de bloqueio e sincronização mais robustos são frequentemente necessários para garantir a integridade dos dados.

Tags: C++ Multithreading File I/O Concurrency cstdio

Publicado em 7-5 05:18