Back To Basics - Everyone Remember Where We Parked (that memory)!
I added a new feature to BabySmash during lunch, so that if your (baby's) mouse wheel is over a shape and they scroll the wheel, the system will play a sound and zoom that object in or out. The mouse wheel events come REALLY fast, as do most mouse events.
The general idea is this. I've got the PInvoke/DllImport call to the PlaySound API and a couple of helper methods. If the WAV wasn't cached, I'd go get it and store it away. All this code was kind of written on auto-pilot, you know? It's called very quickly in the MouseWheel event and works fine...until it totally doesn't work at all.
I found that when I wheeled the mouse REALLY fast, sometimes it'd get a nasty burst of loud static instead of the nice WAV file playing as I expected.
I store my WAV files inside the resources of BabySmash.exe (for now) so I just hold them in memory. Initially I would pull them out of the resource every time, but then I added some basic caching. (I probably should have used Chad and Jeremy's cache, but anyway)
[DllImport("winmm.dll")]
public static extern bool PlaySound(byte[] data, IntPtr hMod, UInt32 dwFlags);
public static void PlayWavResource(string wav)
{
byte[] b = GetWavResource(wav);
PlaySound(b, IntPtr.Zero, SND_ASYNC | SND_MEMORY);
}
public static void PlayWavResourceYield(string wav)
{
byte[] b = GetWavResource(wav);
PlaySound(b, IntPtr.Zero, SND_ASYNC | SND_MEMORY | SND_NOSTOP);
}
private static byte[] GetWavResource(string wav)
{
//TODO: Is this valid double-check caching?
byte[] b = null;
if (cachedWavs.ContainsKey(wav))
b = cachedWavs[wav];
if (b == null)
{
lock (cachedWavsLock)
{
// get the namespace
string strNameSpace = Assembly.GetExecutingAssembly().GetName().Name;
// get the resource into a stream
using (Stream str = Assembly.GetExecutingAssembly().GetManifestResourceStream(strNameSpace + wav))
{
if (str == null)
throw new ArgumentException(wav + " not found!");
// bring stream into a byte array
var bStr = new Byte[str.Length];
str.Read(bStr, 0, (int)str.Length);
cachedWavs.Add(wav, bStr);
return bStr;
}
}
}
return b;
}
Anyway, I kind of forgot that byte was a value type and in a chat this afternoon Larry made this comment. You might remember that the man responsible for the PlaySound() API is none other than Larry Osterman, who I interviewed last year. Here's our chat transcript:
Larry Osterman:
My guess is that you're deleting the array b before the PlaySound has completed.
or rather the CLR is.Scott Hanselman:
even though it's on the stack?
ah
I get it
the GC is getting to itLarry Osterman:
when you say snd_async, it queues the actual playsound operation to a worker thread.
Yup, GC makes it go away.
When I started going really fast with dozens of calls to PlaySound() a second, I was piling these up and eventually hit the point where one byte[] that was being played would disappear (get garbage collected) and I'd hear the sound of zeros being played. Which sounds much like static. (kidding) ;) I could have made the sound play synchronously, but that doesn't fit well with BabySmash's free-form maniacal button pressing.
Larry suggested I copy the WAV files to a temporary location so they'd be "pinned" down, as there wasn't really a good way to pin these in memory that either of us could come up with. Here's what I did. I grabbed a TempFileName, put the WAV files on disk there and switched the call to PlaySound to the filename overloaded version, rather than the byte[] version. I use TempFileCollection which is helpful because it automatically tries to delete the temporary files when its finalizer runs.
[DllImport("winmm.dll", SetLastError = true)]
static extern bool PlaySound(string pszSound, IntPtr hmod, UInt32 fdwSound);
public void PlayWavResource(string wav)
{
string s = GetWavResource(wav);
PlaySound(s, IntPtr.Zero, SND_ASYNC);
}
public void PlayWavResourceYield(string wav)
{
string s = GetWavResource(wav);
PlaySound(s, IntPtr.Zero, SND_ASYNC | SND_NOSTOP);
}
TempFileCollection tempFiles = new TempFileCollection();
private string GetWavResource(string wav)
{
//TODO: Is this valid double-check caching?
string retVal = null;
if (cachedWavs.ContainsKey(wav))
retVal = cachedWavs[wav];
if (retVal == null)
{
lock (cachedWavsLock)
{
// get the namespace
string strNameSpace = Assembly.GetExecutingAssembly().GetName().Name;
// get the resource into a stream
using (Stream str = Assembly.GetExecutingAssembly().GetManifestResourceStream(strNameSpace + wav))
{
string tempfile = System.IO.Path.GetTempFileName();
tempFiles.AddFile(tempfile,false);
var bStr = new Byte[str.Length];
str.Read(bStr, 0, (int)str.Length);
File.WriteAllBytes(tempfile, bStr);
cachedWavs.Add(wav, tempfile);
return tempfile;
}
}
}
return retVal;
}
It's coarse, but it works, and now I can move on to some cleanup with this bug behind me. The Back to Basics lesson for me is:
- Don't forget there is a Garbage Collector out there, doing just that.
- It's easy to forget all about it, but it's so important to know who has a finger on your memory when you're moving back and forth over the unmanaged/managed boundary.
- Edge cases are no fun.
- There are always edge cases, race conditions and deadlocks to be found, and I'm sure I've got more left to find! (notice my lack of surety around the lock() call in the comments?)
- Know your patterns for best practices or, better yet, know where to go to find the answers.
- Your software typically runs exactly as you wrote it.
- Even though this was a GC doing something I didn't expect, it was doing its job with the code I provided it. Given how I was using the byte array, it's very deterministic in its behavior.
- Know what's going wrong before you try to fix it.
- Once I understood the bug, I was able to reproduce the bug much more easily. "Flakies" are scary, but Bugs are just bugs. If I tried to fix it without understanding it, I'd just be programming by coincidence. (And I may still be! That's the rub.)
Have a nice day!
About Scott
Scott Hanselman is a former professor, former Chief Architect in finance, now speaker, consultant, father, diabetic, and Microsoft employee. He is a failed stand-up comic, a cornrower, and a book author.
About Newsletter
Does anyone know of a way to play sounds async without writing them to disk first?
Oh, and i noticed you do
Stream str = Assembly.GetExecutingAssembly().GetManifestResourceStream(strNameSpace + wav)Why don't you put all the sounds in an embedded .resx file? Then you can do TheResourceFile.ResourceManager.GetStream(wav), and you lose the namespace thingy.
System.Media.SoundPlayer was introduced in framework 2.0.
(http://msdn.microsoft.com/en-us/library/system.media.soundplayer(VS.80).aspx)
You could set the Stream property to your manifest resource stream, use either the LoadAsync() or Load() methods, then use the Play(), PlayLooping(), PlaySync().
(or did I miss something?)
How about you switch it back (code is at CodePlex) and let me know? ;)
BTW, those babies turn into teenagers (eventually), which is kind of where I'm at now. So, I'll have to find time between battles. When mine were little, people would say to me "enjoy them while you can". Now I know what they were getting at.
Maybe I'll do "teen smash" where I actually play the game and try to stop drinking, drugs, sex, etc., and the game elements just tell me I'm stupid, don't know nothing, and I "just need to chill out".
So enjoy it while you can!
As I recall, they create/hold an internal byte[] that you can access.
This would give code that looked something like this:
static Dictionary<string, MemoryStream> cachedWavs = new Dictionary<string, MemoryStream>();
public static void PlayWavResourceYield(string wav)
{
MemoryStream str = GetWavResource(wav);
PlaySound(str.GetBuffer(), IntPtr.Zero, SND_ASYNC | SND_MEMORY | SND_NOSTOP);
}
private static byte[] GetWavResource(string wav)
{
//TODO: Is this valid double-check caching?
byte[] b = null;
if (cachedWavs.ContainsKey(wav))
b = cachedWavs[wav];
if (b == null)
{
lock (cachedWavsLock)
{
// test if the item has been adding while we waited for lock
if (cachedWavs.ContainsKey(wav))
return cachedWavs[wav];
// get the namespace
string strNameSpace = Assembly.GetExecutingAssembly().GetName().Name;
// get the resource into a stream
using (Stream str = Assembly.GetExecutingAssembly().GetManifestResourceStream(strNameSpace + wav))
{
if (str == null)
throw new ArgumentException(wav + " not found!");
// bring stream into a byte array
var bStr = new Byte[str.Length];
str.Read(bStr, 0, (int)str.Length);
// only some of the MemoryStream Ctors allow us to call GetBuffer...
ms = new MemoryStream(bStr.Length);
ms.Write(bStr, 0, bStr.Length);
cachedWavs.Add(wav, ms);
return ms;
}
}
}
return b;
}
As far as I remember, this should handle the byte[] lifetime correctly.
I guess its time to go to sleep now...
Chris - Our belief is that the byte[] is being GC'ed as PlaySound() starts up a new thread and my method leaves. When memory pressure causes a GC, my byte[] is tossed in the middle, or slightly before, the PlaySound thread gets started. I'll try the MemoryStream, but I think it could be GC'ed also, although Jacco's idea is a good one.
AdamR - Probably not, but considering that the stream is coming from my own assembly, if it didn't work, I'm in bigger trouble than I can handle. I could add an Assert or try/cach.
public static void PlayWavResourceYield(string wav)
{
byte[] b = GetWavResource(wav);
PlaySound(b, IntPtr.Zero, SND_ASYNC | SND_MEMORY | SND_NOSTOP); // Only fixed during this call
}
Fun stuff!
byte may be a value type, but byte[] isn't. So if every byte[] you pass to PlaySound is also stored in cachedWavs, then how can it be garbage collected? cachedWavs still has a reference to the same instance that PlaySound uses! So unless cachedWavs is not garbage collected, neither should the byte[].
Can someone please explain to me where I'm wrong?
Thanks!
The most direct way to prevent this is to cache a pinned GCHandle to the object, created like this:
GCHandle wavHandle = GCHandle.Alloc(b, GCHandleType.Pinned);
Then pass wavHandle.AddrOfPinnedObject to PlaySound.
The other problem the incorrect double-guard in the caching. In the original code one thread could be preempted while creating the wav, allowing another to enter the method and pass through the ContainsKey check. It will stop at the lock statement, but would then continue once the original thread leaves the lock block. The second thread would recreate the wav and update the dictionary with it, causing an exception due to duplicate entries with the same key.
Also, use Dictionary.TryGetValue() to avoid looking up the dictionary twice.
byte[] b;
if ( ! cachedWave.TryGetValue(wav, out b) )
{
lock ( cachedWavsLock )
{
if ( ! cachedWavs.TryGetValue(wav, out b) )
{
b = new ...
...
cachedWavs.Add(wav, b);
}
}
}
return b;
If I understand correctly another solution for the problem would be to spin up a worker thread in managed code and then call PlaySound synchronously in this worker thread, right?
Getting back to the current approach (P/Invoke), one idea would be to put the WAV data in unmanaged memory. I am not too familiar with the GC (pinning and all), but I am with P/Invoke and the Marshal class. You could allocate unmanaged memory, copy over there, and remember to free it later. The only thing I like about this idea is that if we are going to use P/Invoke to call the (potentially Async) windows API, then it seems to be natural that the data buffer be "over there" in unmanaged land as well.
Here's some more info:
http://blogs.msdn.com/kimhamil/archive/2008/03/08/hashtable-and-dictionary-thread-safety-considerations.aspx
You could just use the synchronous version of PlaySound, wrap it in a delegate and call BeingInvoke(); TA DA! No writing sounds to disk!
foo()
{
char buffer[100] = "ding.wav";
PlaySound(buffer, NULL, SND_ASYNC);
}
One other suggestion I made to Scott to avoid the temporary file thingy is to use SND_RESOURCE and specify a resource ID and module handle for the first and second parameters to the function.
I'll have to take a look at the code later tonight to see if there's something about the cache in your app that you haven't told us.
The only thing I can think might cause this is if your cache is just pointing to the space created during GetManifestResourceStream (which doesn't appear to be the case because Stream.Read should copy the data into the byte array) and the GC disregards that reference and collects anyway. Or if the ASP.NET cache is somehow exempt from proper GC.
I want to put this on a laptop without an internet connection and I dont want to connect it to the net either....... please?
However, it IS possible that the GC is moving the array in managed memory, which could potentially have the same result. Has anyone tried the suggestion of using GCHandle and/or unmanaged memory? If not I might give it a try.
@Jay, the problem with rolling your own is that the async support relies on the threadpool, and there are a limited number of threads in the pool. With dozens of invokes a second you may very well eat the pool.
-Walden
Comments are closed.
Or alternatively: "Pinball Programming" (via http://badprogrammer.infogami.com)