Trabalhando com Stream no C#
Neste artigo, a gente vai falar sobre Streams no C#. Se você já precisou abrir um arquivo grandão ou mandar dados pela rede, mesmo sem saber, provavelmente já esbarrou com algum tipo de stream. Bora ver alguns detalhes!
Streams
No C#, um Stream é uma forma de ler ou escrever dados em partes, ao invés de fazer tudo de uma vez. Isso é importante quando lidamos com arquivos grandes, pois conseguimos fazer parte por parte ao invés de trabalhar com o arquivo inteiro. Imagine que um stream é um rio e os dados é a água que percorre o rio.
A classe stream
no C# é abstrata e fornece métodos para lidar com bytes. Temos classes que lidam com tipos de fontes diferentes que herdam de stream
para fornecer as funcionalidades necessárias como: MemoryStream
, NetworkStream
, etc.
Hierarquia de Streams no .NET
Estrutura das principais classes de stream:
System.Object
│
└── System.MarshalByRefObject
│
└── System.IO.Stream (classe base abstrata)
├── FileStream
├── MemoryStream
├── NetworkStream
├── CryptoStream
├── GZipStream
├── DeflateStream
├── BufferedStream
└── PipeStream (usado com NamedPipes)
Leitores e Escritores de stream:
System.Object
│
├── System.IO.TextReader (abstrata)
│ └── StreamReader
│
├── System.IO.TextWriter (abstrata)
│ └── StreamWriter
├── System.IO.BinaryReader
└── System.IO.BinaryWriter
Cursor e Buffer
O que são?
- Buffer: é um espaço temporário na memória que armazena parte dos dados que estão chegando pelo fluxo(
stream
). Na explicação acima, onde comparamos que o stream é um rio e a água é os dados, o buffer seria um balde para pegar a água. - Cursor: é a posição atual dentro do fluxo(
stream
), informando onde a leitura ou escrita está acontecendo. A posição do cursor (ou ponteiro) é sempre movida à medida que os dados são lidos ou escritos.
Exemplo prático
class Program
{
static void Main()
{
// Criando um arquivo para demonstrar a manipulação do cursor e buffer
string caminhoArquivo = "exemplo.txt";
// Vamos garantir que o arquivo está limpo antes de começar
if (File.Exists(caminhoArquivo))
File.Delete(caminhoArquivo);
// Criando um FileStream para escrever dados no arquivo
using (FileStream fs = new FileStream(caminhoArquivo, FileMode.Create, FileAccess.Write))
{
Console.WriteLine("Escrevendo dados no arquivo...");
byte[] dados = new byte[] { 65, 66, 67, 68, 69 }; // Representa A, B, C, D, E
fs.Write(dados, 0, dados.Length); // Escreve os dados no arquivo
Console.WriteLine("Posição do cursor após escrita: " + fs.Position); // Posição após a escrita
}
// Agora vamos ler os dados do arquivo e ver como a posição do cursor muda
using (FileStream fs = new FileStream(caminhoArquivo, FileMode.Open, FileAccess.Read))
{
Console.WriteLine("\\nLendo dados do arquivo...");
byte[] buffer = new byte[2]; // Buffer de 2 bytes
int bytesLidos;
// Lendo em blocos de 2 bytes
while ((bytesLidos = fs.Read(buffer, 0, buffer.Length)) > 0)
{
Console.WriteLine($"Lido: {BitConverter.ToString(buffer, 0, bytesLidos)}");
Console.WriteLine("Posição do cursor após leitura: " + fs.Position); // Posição após cada leitura
}
}
// Usando Seek para mover o cursor manualmente
using (FileStream fs = new FileStream(caminhoArquivo, FileMode.Open, FileAccess.Read))
{
// Movendo o cursor para a posição inicial
fs.Seek(0, SeekOrigin.Begin);
Console.WriteLine("\\nApós Seek, posição do cursor: " + fs.Position);
// Lendo os primeiros 3 bytes (A, B, C)
byte[] buffer = new byte[3];
fs.Read(buffer, 0, buffer.Length);
Console.WriteLine("Dados lidos após Seek: " + BitConverter.ToString(buffer));
// Movendo o cursor para a posição 2
fs.Seek(2, SeekOrigin.Begin);
Console.WriteLine("Após Seek(2), posição do cursor: " + fs.Position);
// Lendo o byte na posição 2 (C)
byte[] buffer2 = new byte[1];
fs.Read(buffer2, 0, buffer2.Length);
Console.WriteLine("Dados lidos após Seek(2): " + BitConverter.ToString(buffer2));
}
}
}
Resultado do console:
Escrevendo dados no arquivo...
Posição do cursor após escrita: 5
Lendo dados do arquivo...
Lido: 41-42
Posição do cursor após leitura: 2
Lido: 43-44
Posição do cursor após leitura: 4
Lido: 45
Posição do cursor após leitura: 5
Após Seek, posição do cursor: 0
Dados lidos após Seek: 41-42-43
Após Seek(2), posição do cursor: 2
Dados lidos após Seek(2): 43
OBS: os valores lidos são retornados em HEX.
Veja que a classe abstrata Stream
nos fornece um controle mais refinado sobre os dados que chegam pelo fluxo, podendo ler e escrever na posição que deseja. Se tratando de strings temos o FileStream
que traz esse controle maior, mas caso queiramos algo mais abstraído, temos as classes StreamReader
/Writer
que facilita o trabalho com strings.
Veja a simplificação:
class Program
{
static void Main()
{
// Caminho do arquivo
string caminhoArquivo = "exemplo.txt";
// Garantindo que o arquivo está limpo antes de começar
if (File.Exists(caminhoArquivo))
File.Delete(caminhoArquivo);
// Criando um StreamWriter para escrever dados no arquivo
using (StreamWriter writer = new StreamWriter(caminhoArquivo))
{
Console.WriteLine("Escrevendo dados no arquivo...");
writer.WriteLine("A");
writer.WriteLine("B");
writer.WriteLine("C");
writer.WriteLine("D");
writer.WriteLine("E");
// Não precisamos mais gerenciar a posição do cursor
// StreamWriter já cuida disso automaticamente
}
// Agora vamos ler os dados do arquivo usando StreamReader
using (StreamReader reader = new StreamReader(caminhoArquivo))
{
Console.WriteLine("\\nLendo dados do arquivo...");
string linha;
while ((linha = reader.ReadLine()) != null)
{
Console.WriteLine($"Lido: {linha}");
}
}
}
}
Quando usar cada um?
Vale ressaltar que pelo próprio nome, o FileStream
fica explicito que ele trabalha com arquivos(binários), podendo ser imagens, vídeos, arquivos, texto, etc. Já a classe StreamReader
e Writer
é especializa para trabalhar com texto.
Boas práticas
Todas as classes de Stream
trabalham diretamente com bytes e também herdam o IDisposable
.
Para gravar uma string
, é necessário convertê-la antes com Encoding.UTF8.GetBytes()
:
using System;
using System.Text;
using System.IO;
using System.Threading;
namespace StreamsAndAsync {
public class Program {
static void Main(string[] args) {
string testMessage = "Testing writing some arbitrary string to a stream";
byte[] messageBytes = Encoding.UTF8.GetBytes(testMessage);
using (Stream ioStream = new FileStream(@"stream_demo_file.txt", FileMode.OpenOrCreate)) {
if (ioStream.CanWrite) {
ioStream.Write(messageBytes, 0, messageBytes.Length);
} else {
Console.WriteLine("Couldn't write to our data stream.");
}
}
Console.WriteLine("Done!");
}
}
}
- Sempre utilize
using
para garantir o fechamento adequado do fluxo e liberação de recursos não gerenciados. - Valide as propriedades do
Stream
(comoCanWrite
ouCanRead
) antes de realizar operações.
Comparativo entre tipos de Stream
Classe | Tipo de Dados | Direção | Usado com | Contexto Comum |
---|---|---|---|---|
FileStream |
Binário (bytes) | Leitura/Escrita | Arquivos | Manipulação de arquivos em baixo nível |
StreamReader |
Texto (caracteres) | Leitura | Envolve um Stream |
Leitura de arquivos de texto |
StreamWriter |
Texto (caracteres) | Escrita | Envolve um Stream |
Escrita de arquivos de texto |
MemoryStream |
Binário (bytes) | Leitura/Escrita | Memória RAM | Manipulação temporária de dados em memória |
NetworkStream |
Binário (bytes) | Leitura/Escrita | Sockets (TcpClient , TcpListener ) |
Comunicação pela rede |
GZipStream |
Binário (bytes) compactado | Leitura/Escrita | Envolve outro Stream |
Compressão e descompressão de dados |
Mostrando o real valor de usar Stream
Vamos realizar uma operação de buscar, em um arquivo de log com o tamanho de ≅510 MB, a palavra “erro”. Deixarei um script em python para gerar o arquivo de log:
import random
import os
def generate_large_log_file_with_error(filename, size_in_gb):
size_in_bytes = size_in_gb * 1024 * 1024 * 500
halfway_size = size_in_bytes // 2
words = ["error", "warning", "info", "critical", "system", "failure", "success", "timeout", "load", "connection"]
with open(filename, 'w') as log_file:
while os.path.getsize(filename) < size_in_bytes:
line = " ".join(random.choices(words, k=random.randint(5, 15)))
if os.path.getsize(filename) >= halfway_size:
line = line.replace(random.choice(words), "erro", 1)
log_file.write(line + '\\n')
generate_large_log_file_with_error('log.txt', 1)
Abaixo temos dois métodos para ler esse arquivo e procurar pela palavra “erro”, um que usará o stream
e o outro carregara o arquivo todo em memória. Devo dizer que o desempenho irá variar de acordo com o tamanho do arquivo e esse código é para fins de demonstração dos benefícios de usar o stream
apenas:
[MemoryDiagnoser]
[RankColumn]
[SimpleJob(targetCount: 100)]
public class CaseStream
{
public string CaminhoArquivo { get; set; } = "caminho_do_arquivo";
[Benchmark]
public async Task LerArquivosStreamAsync()
{
try
{
using FileStream fs = new(CaminhoArquivo, FileMode.Open, FileAccess.Read);
using BufferedStream bs = new(fs, 1024 * 1024 * 2);
using StreamReader sr = new(bs);
string linha;
while ((linha = await sr.ReadLineAsync()) != null)
{
var span = linha.AsSpan();
if (span.IndexOf("Erro") >= 0)
{
Console.WriteLine($"Palavra 'Erro' encontrada: {linha}");
break;
}
}
if (linha == null)
{
Console.WriteLine("A palavra 'Erro' não foi encontrada no arquivo.");
}
}
catch (Exception ex)
{
Console.WriteLine("Erro ao ler o arquivo: " + ex.Message);
}
}
[Benchmark]
public async Task LerArquivoInteiroAsync()
{
try
{
string conteudo = await File.ReadAllTextAsync(CaminhoArquivo);
if (conteudo.Contains("Erro"))
{
Console.WriteLine("A palavra 'Erro' foi encontrada no arquivo.");
}
else
{
Console.WriteLine("A palavra 'Erro' não foi encontrada.");
}
}
catch (Exception ex)
{
Console.WriteLine("Erro ao ler o arquivo: " + ex.Message);
}
}
}
Em cada cenário haverá uma forma melhor para realizar essa operação e recomendo que no seu cenário, avalie e teste diferentes implementações. Abaixo está o resultado usando o Benchmark.NET para medir:
Method | Mean | Error | StdDev | Rank | Gen0 | Gen1 | Gen2 | Allocated |
---|---|---|---|---|---|---|---|---|
LerArquivosStreamAsync | 436.0 ms | 3.86 ms | 10.94 ms | 1 | 101000.0000 | - | - | 1.58 GB |
LerArquivoInteiroAsync | 1,859.0 ms | 43.18 ms | 127.31 ms | 2 | 86000.0000 | 85000.0000 | 6000.0000 | 1.98 GB |
Vemos que no geral, o stream
se sobressai em relação a segunda implementação. Alguns pontos importantes é se atentar ao tamanho do buffer que definimos, por padrão o buffer usado é de 1KB no FileStream
e no BufferedStream
é 4KB e isso impacta no Garbage Collection por ter que realizar as limpezas desses buffers, pois quanto menor, mais limpezas ocorrerão.
Conclusão
Com isso, vemos que o Stream é um grande aliado quando necessitamos de trabalhar com arquivos grandes, pois ele vai fazendo o serviço aos poucos sem travar a thread e também na maioria dos casos, economizará memória da sua aplicação.