Search code examples
c#streamreader

Streamreader with custom LineBreak - Performance optimisation


Edit: See my Solution below...

I had the following Problem to solve: We receive Files (mostly adress-Information) from different sources, these can be in Windows Standard with CR/LF ('\r''\n') as Line Break or UNIX with LF ('\n').

When reading text in using the StreamReader.ReadLine() method, this is no Problem because it handles both cases equally.

The Problem occurs when you have a CR or a LF somewhere in the File that is not supposed to be there. This happens for example if you Export a EXCEL-File with Cells that contain LineBreaks within the Cell to .CSV or other Flat-Files.

Now you have a File that for example has the following structure:

FirstName;LastName;Street;HouseNumber;PostalCode;City;Country'\r''\n'
Jane;Doe;co James Doe'\n'TestStreet;5;TestCity;TestCountry'\r''\n'
John;Hancock;Teststreet;1;4586;TestCity;TestCounty'\r''\n'

Now the StreamReader.ReadLine() Method reads the First Line as:

FirstName;LastName;Street;HouseNumber;PostalCode;City;Country

Which is fine but the seccond Line will be:

Jane;Doe;co James Doe

This will either break your Code or you will have false Results, as the following Line will be:

TestStreet;5;TestCity;TestCountry

So we usualy ran the File trough a tool that checks if there are loose '\n' or '\r' arround and delete them.

But this step is easy to Forget and so I tried to implement a ReadLine() method of my own. The requirement was that it would be able to use one or two LineBreak characters and those characters could be defined freely by the consuming logic.

This is the Class that I came up with:

 public class ReadFile
{
    private FileStream file;
    private StreamReader reader;

    private string fileLocation;
    private Encoding fileEncoding;
    private char lineBreak1;
    private char lineBreak2;
    private bool useSeccondLineBreak;

    private bool streamCreated = false;

    private bool endOfStream;

    public bool EndOfStream
    {
        get { return endOfStream; }
        set { endOfStream = value; }
    }

    public ReadFile(string FileLocation, Encoding FileEncoding, char LineBreak1, char LineBreak2, bool UseSeccondLineBreak)
    {
        fileLocation = FileLocation;
        fileEncoding = FileEncoding;
        lineBreak1 = LineBreak1;
        lineBreak2 = LineBreak2;
        useSeccondLineBreak = UseSeccondLineBreak;
    }

    public string ReadLine()
    {
        if (streamCreated == false)
        {
            file = new FileStream(fileLocation, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
            reader = new StreamReader(file, fileEncoding);

            streamCreated = true;
        }

        StringBuilder builder = new StringBuilder();
        char[] buffer = new char[1];
        char lastChar = new char();
        char currentChar = new char();

        bool first = true;
        while (reader.EndOfStream != true)
        {
            if (useSeccondLineBreak == true)
            {
                reader.Read(buffer, 0, 1);
                lastChar = currentChar;

                if (currentChar == lineBreak1 && buffer[0] == lineBreak2)
                {
                    break;
                }
                else
                {
                    currentChar = buffer[0];
                }

                if (first == false)
                {
                    builder.Append(lastChar);
                }
                else
                {
                    first = false;
                }
            }
            else
            {
                reader.Read(buffer, 0, 1);

                if (buffer[0] == lineBreak1)
                {
                    break;
                }
                else
                {
                    currentChar = buffer[0];
                }

                builder.Append(currentChar);
            }
        }

        if (reader.EndOfStream == true)
        {
            EndOfStream = true;
        }

        return builder.ToString();
    }

    public void Close()
    {
        if (streamCreated == true)
        {
            reader.Close();
            file.Close();
        }
    }
}

This code works fine, it does what it is supposed to do but compared to the original StreamReader.ReadLine() method, it is ~3 Times slower. As we work with large row-Counts the difference is not only messured but also reflected in real world Performance. (for 700'000 Rows it takes ~ 5 Seconds to read all Lines, extract a Chunk and write it to a new File, with my method it takes ~15 Seconds on my system)

I tried different aproaches with bigger buffers but so far I wasn't able to increase Performance.

What I would be interessted in: Any suggestions how I could improve the performance of this code to get closer to the original Performance of StreamReader.ReadLine()?

Solution:

This now takes ~6 Seconds (compared to ~5 Sec using the Default 'StreamReader.ReadLine()' ) for 700'000 Rows to do the same things as the code above does.

Thanks Jim Mischel for pointing me in the right direction!

public class ReadFile
    {
        private FileStream file;
        private StreamReader reader;

        private string fileLocation;
        private Encoding fileEncoding;
        private char lineBreak1;
        private char lineBreak2;
        private bool useSeccondLineBreak;

        const int BufferSize = 8192;
        int bufferedCount;
        char[] rest = new char[BufferSize];
        int position = 0;

        char lastChar;
        bool useLastChar;

        private bool streamCreated = false;

        private bool endOfStream;

        public bool EndOfStream
        {
            get { return endOfStream; }
            set { endOfStream = value; }
        }

        public ReadFile(string FileLocation, Encoding FileEncoding, char LineBreak1, char LineBreak2, bool UseSeccondLineBreak)
        {
            fileLocation = FileLocation;
            fileEncoding = FileEncoding;
            lineBreak1 = LineBreak1;
            lineBreak2 = LineBreak2;
            useSeccondLineBreak = UseSeccondLineBreak;
        }
 
        private int readInBuffer()
        {
            return reader.Read(rest, 0, BufferSize);
        }

        public string ReadLine()
        {
            StringBuilder builder = new StringBuilder();
            bool lineFound = false;

            if (streamCreated == false)
            {
                file = new FileStream(fileLocation, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 8192);

                reader = new StreamReader(file, fileEncoding);

                streamCreated = true;

                bufferedCount = readInBuffer();
            }
            
            while (lineFound == false && EndOfStream != true)
            {
                if (position < bufferedCount)
                {
                    for (int i = position; i < BufferSize; i++)
                    {
                        if (useLastChar == true)
                        {
                        useLastChar = false;

                        if (rest[i] == lineBreak2)
                        {
                            count++;
                            position = i + 1;
                            lineFound = true;
                            break;
                        }
                        else
                        {
                            builder.Append(lastChar);
                        }
                        }

                        if (rest[i] == lineBreak1)
                        {
                            if (useSeccondLineBreak == true)
                            {
                                if (i + 1 <= BufferSize - 1)
                                {
                                    if (rest[i + 1] == lineBreak2)
                                    {
                                        position = i + 2;
                                        lineFound = true;
                                        break;
                                    }
                                    else
                                    {
                                        builder.Append(rest[i]);
                                    }
                                }
                                else
                                {
                                    useLastChar = true;
                                    lastChar = rest[i];
                                }
                            }
                            else
                            {
                                position = i + 1;
                                lineFound = true;
                                break;
                            }
                        }
                        else
                        {
                            builder.Append(rest[i]);
                        }

                        position = i + 1;
                    }
                    
                }
                else
                {
                    bufferedCount = readInBuffer();
                    position = 0;
                }
            }

            if (reader.EndOfStream == true && position == bufferedCount)
            {
                EndOfStream = true;
            }

            return builder.ToString();
        }


        public void Close()
        {
            if (streamCreated == true)
            {
                reader.Close();
                file.Close();
            }
        }
    }

Solution

  • The way to speed this up would be to have it read more than one character at a time. For example, create a 4 kilobyte buffer, read data into that buffer, and then go character-by-character. If you copy character-by-character to a StringBuilder, it's pretty easy.

    The code below shows how to parse out lines in a loop. You'd have to split this up so that it can maintain state between calls, but it should give you the idea.

    const int BufferSize = 4096;
    const string newline = "\r\n";
    
    using (var strm = new StreamReader(....))
    {
        int newlineIndex = 0;
        var buffer = new char[BufferSize];
        StringBuilder sb = new StringBuilder();
        int charsInBuffer = 0;
        int bufferIndex = 0;
        char lastChar = (char)-1;
    
        while (!(strm.EndOfStream && bufferIndex >= charsInBuffer))
        {
            if (bufferIndex > charsInBuffer)
            {
                charsInBuffer = strm.Read(buffer, 0, buffer.Length);
                if (charsInBuffer == 0)
                {
                    // nothing read. Must be at end of stream.
                    break;
                }
                bufferIndex = 0;
            }
            if (buffer[bufferIndex] == newline[newlineIndex])
            {
                ++newlineIndex;
                if (newlineIndex == newline.Length)
                {
                    // found a line
                    Console.WriteLine(sb.ToString());
                    newlineIndex = 0;
                    sb = new StringBuilder();
                }
            }
            else
            {
                if (newlineIndex > 0)
                {
                    // copy matched newline characters
                    sb.Append(newline.Substring(0, newlineIndex));
                    newlineIndex = 0;
                }
                sb.Append(buffer[bufferIndex]);
            }
            ++bufferIndex;
        }
        // Might be a line left, without a newline
        if (newlineIndex > 0)
        {
            sb.Append(newline.Substring(0, newlineIndex));
        }
        if (sb.Length > 0)
        {
            Console.WriteLine(sb.ToString());
        }
    }
    

    You could optimize this a bit by keeping track of the starting position so that when you find a line you create a string from buffer[start] to buffer[current], without creating a StringBuilder. Instead you call the String(char[], int32, int32) constructor. That's a little tricky to handle when you cross a buffer boundary. Probably would want to handle crossing the buffer boundary as a special case and use a StringBuilder for temporary storage in that case.

    I wouldn't bother with that optimization, though, until after I got this first version working.