6 ms·
> So while you _do_ have to serialize access to shared IO resources, you do _not_ have to serialize access to memory Yes, in Node you don't get the usual data
by bheadmaster 3y ago
> So while you _do_ have to serialize access to shared IO resources, you do _not_ have to serialize access to memory
Yes, in Node you don't get the usual data races like in C++, but data-structure races can be just as dangerous. E.g. modifying the same array/object from two interleaved async functions was a common source of bugs in the systems I've referred to.
Of course, you can always rely on your code being synchronous and thus not needing a lock, but if you're doing anything asynchronous and you want a guarantee that your data will not be mutated from another async function, you need a lock, just like in ordinary threads.
One thing I deeply dislike about Node is how it convinces programmers that async/await is special, different from threading, and doesn't need any synchronisation mechanisms because of some Node-specific implementation details. This is fundamentally wrong and teaches wrong practices when it comes to concurrency.
- nurple 3y agoBut single-threaded async/await _is_ special and different from multi-threaded concurrency. Placing it in the same basket and prescribing the same method of use is fundamentally wrong and fails to teach the magic of idiomatic lock free async javascript. I'm honestly having a difficult time creating a steel man js sample that exhibits data races unless I write weird C-like constructs and ignore closures and async flows to pass and mutate multi-element variables by reference deep into the call stack. This just isn't how js is written. When you think about async/await in terms of shepherding data flows it becomes pretty easy to do lock free async/await with guaranteed serialization sans locks.
- bheadmaster 3y ago> I'm honestly having a difficult time creating a steel man js sample that exhibits data races I can give you a real-life example I've encountered: const CACHE_EXPIRY = 1000; // Cache expiry time in milliseconds let cache = {}; // Shared cache object function getFromCache(key) { const cachedData = cache[key]; if (cachedData && Date.now() - cachedData.timestamp < CACHE_EXPIRY) { return cachedData.data; } return null; // Cache entry expired or not found } function updateCache(key, data) { cache[key] = { data, timestamp: Date.now(), }; } var mockFetchCount = 0; // simulate web request shorter than cache time async function mockFetch(url) { await new Promise(resolve => setTimeout(resolve, 100)); mockFetchCount += 1; return `result from ${url}`; } async function fetchDataAndUpdateCache(key) { const cachedData = getFromCache(key); if (cachedData) { return cachedData; } // Simulate fetching data from an external source const newData = await mockFetch(`https://example.com/data/${key}`); // Placeholder fetch updateCache(key, newData); return newData; } // Race condition: (async () => { const key = 'myData'; // Fetch data twice in a sequence - OK await fetchDataAndUpdateCache(key); await fetchDataAndUpdateCache(key); console.log('mockFetchCount should be 1:', mockFetchCount); // Reset counter and wait cache expiry mockFetchCount = 0; await new Promise(resolve => setTimeout(resolve, CACHE_EXPIRY)); // Fetch data twice concurrently - we executed fetch twice! await Promise.all([fetchDataAndUpdateCache(key), fetchDataAndUpdateCache(key)]); console.log('mockFetchCount should be 1:', mockFetchCount); })(); This is what happens when you convince programmers that concurrency is not a problem in JavaScript. Even though this cache works for sequential fetching and will pass trivial testing, as soon as you have concurrent fetching, the program will execute multiple fetches in parallel. If server implements some rate-limiting, or is simply not capable of handling too many parallel connections, you're going to have a really bad time. Now, out of curiosity, how would you implement this kind of cache in idiomatic, lock-free javascript?
- nurple 3y agoDon't forget to fung futures that are fungible for the same key. ETA: I appreciate the time you took to make the example, also I changed the extension to `mjs` so the async IIFE isn't needed. const CACHE_EXPIRY = 1000; // Cache expiry time in milliseconds let cache = {}; // Shared cache object let futurecache = {}; // Shared cache of future values function getFromCache(key) { const cachedData = cache[key]; if (cachedData && Date.now() - cachedData.timestamp < CACHE_EXPIRY) { return cachedData.data; } return null; // Cache entry expired or not found } function updateCache(key, data) { cache[key] = { data, timestamp: Date.now(), }; } var mockFetchCount = 0; // simulate web request shorter than cache time async function mockFetch(url) { await new Promise(resolve => setTimeout(resolve, 100)); mockFetchCount += 1; return `result from ${url}`; } async function fetchDataAndUpdateCache(key) { // maybe its value is cached already const cachedData = getFromCache(key); if (cachedData) { return cachedData; } // maybe its value is already being fetched const future = futurecache[key]; if(future) { return future; } // Simulate fetching data from an external source const futureData = mockFetch(`https://example.com/data/${key}`); // Placeholder fetch futurecache[key] = futureData; const newData = await futureData; delete futurecache[key]; updateCache(key, newData); return newData; } const key = 'myData'; // Fetch data twice in a sequence - OK await fetchDataAndUpdateCache(key); await fetchDataAndUpdateCache(key); console.log('mockFetchCount should be 1:', mockFetchCount); // Reset counter and wait cache expiry mockFetchCount = 0; await new Promise(resolve => setTimeout(resolve, CACHE_EXPIRY)); // Fetch data twice concurrently - we executed fetch twice! await Promise.all([...Array(100)].map(() => fetchDataAndUpdateCache(key))); console.log('mockFetchCount should be 1:', mockFetchCount);
- bheadmaster 3y agoI see, this piece of code seems to be crucial: // maybe its value is already being fetched const future = futurecache[key]; if(future) { return future; } It indeed fixes the problem in a JS lock-free way. Note that, as wolfgang42 has shown in a sibling comment, the original cache map isn't necessary if you're using a future map, since the futures already contain the result: async function fetchDataAndUpdateCache(key) { // maybe its value is cached already const cachedData = getFromCache(key); if (cachedData) { return cachedData; } // Simulate fetching data from an external source const newDataFuture = mockFetch(`https://example.com/data/${key}`); // Placeholder fetch updateCache(key, newDataFuture); return newDataFuture; } --- But note that this kind of problem is much easier to fix than to actually diagnose. My hypothesis is that the lax attutide of Node programmers towards concurrency is what causes subtle bugs like these to happen in the first place. Python, for example, also has single-threaded async concurrency like Node, but unlike Node it also has all the standard synchronization primitives also implemented in asyncio: https://docs.python.org/3/library/asyncio-sync.html https://docs.python.org/3/library/asyncio-sync.html