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,fcntlem 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.