3

参考书籍:《Effective JavaScript》

并发

在JavaScript中,编写响应多个并发事件的程序的方法非常人性化,而且强大,因为它使用了一个简单的执行模型(有时称为事件队列或事件循环并发)和被称为异步的API。

不要阻塞I/O事件队列

在一些语言中,我们会习惯性地编写代码来等待某个特定的输入。

var text = downloadSync('http://example.com/file.txt');
console.log(text);

形如downloadSync这样的函数被称为同步函数(或阻塞函数)。程序会停止做任何工作,而等待它的输入。在这个例子中,也就是等待从网络下载文件的结果。由于在等待下载完成的期间,计算机可以做其他有用的工作,因此这样的语言通常为程序员提供一种方法来创建多个线程,即并行执行自己算。它允许程序的一部分停下来等待(阻塞)一个低速的输入,而程序的另一部分可以继续进行独立的工作。

在JavaScript中,大多的I/O操作都提供了异步的或非阻塞的API。

downloadAsync('http://example.com/file.txt', function (text) {
    console.log(text);
});

该API初始化下载进程,然后在内部注册表中存储了回调函数后立刻返回,而不是被网络请求阻塞。

JavaScript有时被称为提供一个运行到完成机制(run-to-completion)的担保。任何当前正在运行于共享上下文的用户代码,比如浏览器中的单个Web页面或者单个运行的Web服务器实例,只有在执行完成后才能调用下一个事件处理程序。实际上,系统维护了一个按事件发生顺序排列的内部事件队列,一次调用一个已注册的回调函数。

以客户端(mouse moved、file downloaded)和服务器端(file read、path resolved)应用程序事件为例,随着事件的发生,它们被添加到应用程序的事件队列的末尾。JavaScript系统使用一个内部循环机制来执行应用程序。该循环机制每次都拉取队列底部的事件,也就是说,以接收到这些事件的顺序来调用这些已注册的JavaScript事件处理程序,并将事件的数据作为改事件处理程序的参数。

运行到完成机制担保的好处是当代码运行时,你完全掌握应用程序的状态。你根本不必担心一些变量和对象属性的改变由于并发执行代码而超出你的控制。并发编程在JavaScript中往往比使用线程和锁的C++、Java或C#要容易得多。

然而,运行到完成机制的不足是,实际上所有你编写的代码支撑着余下应用程序的继续执行。

JavaScript并发的一个最重要的规则是绝不要在应用程序事件队列中使用阻塞I/O的API。

相比之下,异步的API用在基于事件的环境中是安全的,因为它们迫使应用程序逻辑在一个独立的事件循环“轮询”中继续处理。

提示:

  • 异步APi使用回调函数来延缓处理代价高昂的操作以避免阻塞主应用程序。
  • JavaScript并发地接收事件,但会使用一个事件队列按序地处理事件处理程序。
  • 在应用程序事件队列中绝不要使用阻塞的I/O。

在异步序列中使用嵌套或命名的回调函数

理解操作序列的最简单的方式是异步API是发起操作而不是执行操作。异步操作完成后,在事件循环的某个单独的轮次中,被注册的事件处理程序才会执行。

如果你需要在发起一个操作后做一些事情,如何串联已完成的异步操作。

  • 最简单的答案是使用嵌套。

    db.lookupAsyc('url', function(url) {
        downloadAsyc(url, function(text) {
            console.log('contents of ' + url + ': ' + text);
        });
    });

    嵌套的异步操作很容易,但当扩展到更长的序列时会很快变得笨拙。

    db.lookupAsync('url', function(url) {
        downloadAsync(url, function(file) {
            downloadAsync('a.txt', function(a) {
                downladAsync('b.txt', function(b) {
                    downloadAsync('c.txt', function(c) {
                        // ...
                    });
                })
            });
        });
    });
  • 减少过多嵌套的方法之一是将嵌套的回调函数作为命名的函数,并将它们需要的附加数据作为额外的参数传递。

    db.lookupAsync('url', downloadURL);
    
    function downloadURL(url) {
        downloadAsync(url, function(text) { // still nested
            showContents(url, text);
        });
    }
    
    function showContents(url, text) {
        console.log('contents of ' + url + ': ' + text);
    }

    上述代码仍然使用了嵌套的回调函数,可以使用bind方法消除最深层的嵌套回调函数。

    db.lookupAsync('url', downloadURL);
    
    function downloadURL(url) {
        downloadAsync(url, showContents.bind(null, url)); // => window.showContents(url) = function(url, text) { ... } 
    }
    
    function showContents(url, text) {
        console.log('contents of ' + url + ': ' + text);
    }   

    这种做法导致了代码看起来根据顺序性,但需要为操作序列的每个中间步骤命名,并且一步步地使用绑定,这可能导致尴尬的情况。

  • 更胜一筹的方法是使用一个额外的抽象来简化。

    function downloadFiles(url, file) {
        downloadAllAsync(['a.txt', 'b.txt', 'c.txt'], function(all) {
            var a = all[0],
                b = all[1],
                c = all[2];
            
            // ...
        });
    }

提示:

  • 使用嵌套或命名的回调函数按顺序地执行多个异步操作。
  • 尝试在过多的嵌套的回调函数和尴尬的命名的非嵌套回调函数之间取得平衡。
  • 避免将可被并行执行的操作顺序化。

当心丢弃错误

对于同步的代码,通过使用try语句块包装一段代码很容易一下子处理所有的错误。

try {
    f();
    g();
    h();
} catch (e) {
    // handle any error that occurred...
}

异步的API倾向于将错误表示为回调函数的特定参数,或使用一个附加的错误处理回调函数(有时被称为errbacks)。

downloadAsync('a.txt', function(a) {
    downloadAsync('b.txt', function(b) {
        downloadAsync('c.txt', function(c) {
            console.log('Content: ' + a + b + c);   
        }, function(error) {
            console.log('Error: ' + error);
        })
    }, function(error) { // repeated error-handling logic
        console.log('Error: ' + error);
    })
}, function(error) { // repeated error-handling logic
    console.log('Error: ' + error);
})

上述代码中,每一步的处理都使用了相同的错误处理逻辑,我们可以在一个共享的作用域中定义一个错误处理的函数,将重复代码抽象出来。

function onError(error) {
    console.log('Error: ' + error);
}

downloadAsync('a.txt', function(a) {
    downloadAsync('b.txt', function(b) {
        downloadAsync('c.txt', function(c) {
            console.log('Content: ' + a + b + c);   
        }, onError)
    }, onError)
}, onError)

另一种错误处理API的风格只需要一个回调函数,该回调函数的第一个参数如果有错误发生那就表示为一个错误,否则就位一个假值,比如null。

function onError(error) {
    console.log('Error: ' + error);
}

downloadAsync('a.txt', function(error, a) {
    if (error) return onError(error);

    downloadAsync('b.txt', function(error, b) {
        if (error) return onError(error);

        downloadAsync(url13, function(error, c) {
            if (error) return onError(error);

            console.log('Content: ' + a + b + c);
        });
    });
});

提示:

  • 通过编写共享的错误处理函数来避免复制和粘贴错误处理代码。
  • 确保明确地处理所有的错误条件以避免丢弃错误。

对异步循环使用递归

设想有一个函数接收一个URL的数组并尝试依次下载每个文件。

function downloadOneSync(urls) {
    for (var i = 0, n = urls.length; i < n; i++) {
        downloadAsync(urls[i], onsuccess, function(error) {
            // ?
        });

        // loop continues
    }

    throw new Error('all downloads failed');
}

上述代码将启动所有的下载,而不是等待一个完成再试下一个。

解决方案是将循环实现为一个函数,所以我们可以决定何时开始每次迭代。

function downloadOneAsync(urls, onsuccess, onfailure) {
    var n = urls.length;

    function tryNextURL(i) {
        if (i >= n) {
            onfailure('all downloads failed');
            return;
        }

        downloadAsync(urls[i], onsuccess, function() {
            tryNextURL(i + 1);
        });
    }

    tryNextURL(0);
}

局部函数tryNextURL是一个递归函数。它的实现调用了其自身。目前典型的JavaScript环境中一个递归函数同步调用自身过多次(例如10万次)会导致失败。

JavaScript环境通常在内存中保存一块固定的区域,称为调用栈,用于记录函数调用返回前下一步该做什么。

function negative(x) {
    return abs(x) * -1;
}

function abs(x) {
    return Math.abs(x);
}

console.log(negative(42));

当程序使用参数42调用Math.abs方法时,有好几个其他的函数调用也在进行,每个都在等待另一个的调用返回。

最新的函数调用将信息推入栈(被表示为栈的最底层的帧),该信息也将首先从栈中弹出。当Math.abs执行完毕,将会返回给abs函数,其将返回给negative函数,然后将返回到最外面的脚本。

当一个程序执行中有太多的函数调用,它会耗尽栈空间,最终抛出异常,这种情况被称为栈溢出。

downloadOneAsync函数,不是直到递归调用返回后才被返回,downloadOneAsync只在异步回调函数中调用自身。记住异步API在其回调函数被调用前会立即返回。所以downloadOneAsync返回,导致其栈帧在任何递归调用将新的栈帧推入栈前,会从调用栈中弹出。事实上,回调函数总在事件循环的单独轮次中被调用,事件循环的每个轮次中调用其事件处理程序的调用栈最初是空的。所以无论downloadOneAsync需要多少次迭代,都不会耗尽栈空间。

提示:

  • 循环不能是异步的。
  • 使用递归函数在事件循环的单独轮次中执行迭代。
  • 在事件循环的单独轮次中执行递归,并不会导致调用栈溢出。

不要在计算时阻塞事件队列

如果你的应用程序需要执行代价高昂的算法你该怎么办呢?

也许最简单的方法是使用像Web客户端平台的Worker API这样的并发机制。

但是不是所有的JavaScript平台都提供了类似Worker这样的API,另一种方法是算法分解为多个步骤,每个步骤组成一个可管理的工作块。

Member.prototype.inNetwork = function(other){
    var visited = {},
        worklist = [this];

    while (worklist.length > 0) {
        var member = worklist.pop();

        // ...

        if (member === other) { // found?
            return true;
        }

        // ...
    }

    return false;
};

如果这段程序核心的while循环代价太过高昂,搜索工作很可能会以不可接受的时间运行而阻塞应用程序事件队列。

幸运的是,这种算法被定义为一个步骤集的序列——while循环的迭代。我们可以通过增加一个回调参数将inNetwork转换为一个匿名函数,将while循环替换为一个匿名的递归函数。

Member.prototype.inNetwork = function(other, callback) {
    var visited = {},
        worklist = [this];

    function next() {
        if (worklist.length === 0) {
            callback(false);
            return;
        }

        var number = worklist.pop();

        // ...

        if (member === other) { // found?
            callback(true);
            return;
        }

        // ...

        setTimeout(next, 0); // schedule the next iteration
    }

    setTimeout(next, 0); // schedule the next iteration
};

局部的next函数执行循环中的单个迭代然后调度应用程序事件队列来异步运行下一次迭代。这使得在此期间已经发生的其他事件被处理后才继续下一次迭代。当搜索完成后,通过找到一个匹配或遍历完整个工作表,我们使用结果值调用回调函数并通过调用没有调度任何迭代的next来返回,从而有效地完成循环。

要调度迭代,我们使用多数JavaScript平台都可用的、通用的setTimeout API来注册next函数,是next函数经过一段最少时间(0毫秒)后运行。这具有几乎立刻将回调函数添加到事件队列上的作用。

提示:

  • 避免在主事件队列中执行代价高昂的算法。
  • 在支持Worker API的平台,该API可以用来在一个独立的事件队列中运行长计算程序。
  • 在Worker API不可用或代价昂贵的环境中,考虑将计算程序分解到事件循环的多个轮次中。

使用计数器来执行并行操作

function downloadAllAsync(urls, onsuccess, onerror) {
    var result = [],
        length = urls.length;

    if (length === 0) {
        setTimeout(onsuccess.bind(null, result), 0);
        return;
    }

    urls.forEach(function(url) {
        downloadAsync(url, function(text) {
            if (result) {
                // race condition
                reuslt.push(text);

                if (result.length === urls.length) {
                    onsuccess(result);
                }
            }
        }, function(error) {
            if (result) {
                result = null;
                onerror(error);
            }
        });
    });
}

上述代码有错误。

当一个应用程序依赖于特定的事件顺序才能正常工作时,这个程序会遭受数据竞争(data race)。数据竞争是指多个并发操作可以修改共享的数据结构,这取决于它们发生的顺序。

var filenames = [
    'huge.txt',
    'tiny.txt',
    'medium.txt'
];

downloadAllAsync(filenames, function(files) {
    console.log('Huge file: ' + files[0].length); // tiny
    console.log('Tiny file: ' + files[1].length); // medium
    console.log('Medium file: ' + files[2].length); // huge
}, function(error) {
    console.log('Error: ' + error);
});

由于这些文件是并行下载的,事件可以以任意的顺序发生。例如,如果tiny.txt先下载完成,接下来是medium.txt文件,最后是huge.txt文件,则注册到downloadAllAsync的回调函数并不会按照它们被创建的顺序进行调用。但downloadAllAsync的实现是一旦下载完成就立即将中间结果保存在result数组的末尾。所以downloadAllAsync函数提供的保存下载文件内容的数组的顺序是未知的。

下面的方式可以实现downloadAllAsync不依赖不可预期的事件执行顺序而总能提供预期结果。我们不将每个结果放置到数组末尾,而是存储在其原始的索引位置中。

function downloadAsync(urls, onsuccess, onerror) {
    var length = urls.length,
        result = [];
    
    if (length === 0) {
        setTimeout(onsuccess.bind(null, result), 0);
        return;
    }

    urls.forEach(function(url, i) {
        downloadAsync(url, function(text) {
            if (result) {
                result[i] = text; // store at fixed index

                // race condition
                if (result.length === urls.length) {
                    onsuccess(result);
                }
            }
        }, function(error) {
            if (result) {
                result = null;
                onerror(error);
            }
        });
    });
}

上述代码依然是不正确的。

假如我们有如下的一个请求。

downloadAllAsync(['huge.txt', 'medium.txt', 'tiny.txt']);

根据数组更新的契约,即设置一个索引属性,总是确保数组的length属性值大于索引。

如果tiny.txt文件最先被下载,结果数组将获取索引未2的属性,这将导致result.length被更新为3。用户的success回调函数被过早地调用,其参数为一个不完整的结果数组。

正确地实现应该是使用一个计数器来追踪正在进行的操作数量。

function downloadAsync(urls, onsuccess, onerror) {
    var pending = urls.length,
        result = [];
    
    if (pending === 0) {
        setTimeout(onsuccess.bind(null, result), 0);
        return;
    }

    urls.forEach(function(url, i) {
        downloadAsync(url, function(text) {
            if (result) {
                result[i] = text; // store at fixed index
                pending--; // register the success

                // race condition
                if (pedding === 0) {
                    onsuccess(result);
                }
            }
        }, function(error) {
            if (result) {
                result = null;
                onerror(error);
            }
        });
    });
}

提示:

  • JavaScript应用程序中的事件发生是不确定的,即顺序是不可预测的。
  • 使用计数器避免并行操作中的数据竞争。

绝不要同步地调用异步的回调函数

设想有downloadAsync函数的一个变种,它持有一个缓存来避免多次下载同一个文件。

var cache = new Dict();

function downloadCachingAsync(url, onsuccess, onerror) {
    if (cache.has(url)) {
        onsuccess(cache.get(url)); // synchronous call
        return;
    }

    return downloadAsync(url, function(file) {
        cache.set(url, file);
        onsuccess(file);
    }, onerror);
};

通常情况下,如果可以,它似乎会立即提供数据,但这以微妙的方式违反了异步API客户端的期望。

  • 首先,它改变了操作的预期顺序。

    downloadCachingAsync('file.txt', function(file) {
        console.log('finished'); // might happen first
    });
    
    console.log('starting');
  • 其次,异步API的目的是维持事件循环中每轮的严格分离。这简化了并发,通过减轻每轮事件循环的代码量而不必担心其他代码并发地修改共享的数据结构。同步地调用异步的回调函数违反了这一分离,导致在当前轮完成之前,代码用于执行一轮隔离的事件循环。

    downloadCachingAsync(remaining[0], function(file) {
        remaing.shift();
    
        // ...
    });
    
    status.display('Downloading ' + remaining[0] + '...');

    如果同步地调用该回调函数,那么将显示错误的文件名的消息(或者更糟糕的是,如果队列为空会显示undefined)。

同步地调用异步的回调函数甚至可能会导致一些微妙的问题。

  • 异步的回调函数本质上是以空的调用栈来调用,因此将异步的循环实现为递归函数是安全的,完全没有累计超越调用栈空间的危险。同步的调用不能保障这一点,因而使得一个表面上的异步循环很可能会耗尽调用栈空间。
  • 另一个问题是异常。对于上面的downloadCachingAsync实现,如果回调函数抛出一个异常,它将会在每轮的事件循环中,也就是开始下载时而不是期望的一个分离的回合抛出该异常。

为了确保总是异步地调用回调函数,我们可以使用通用的库函数setTimeout在每隔一个最小的时间的超时时间后给事件队列增加一个回调函数。

var cache = new Dict();

function downloadCachingAsync(url, onsuccess, onerror) {
    if (cache.has(url)) {
        var cached = cache.get(url);
        setTimeout(onsuccess.bind(null, cached), 0);
        return;
    }

    return downloadAsync(url, function(file) {
        cache.set(url, file);
        onsuccess(file);
    }, onerror);
};

提示:

  • 即使可以立即得到数据,也绝不要同步地调用异步回调函数。
  • 同步地调用异步的回调函数扰乱了预期的操作序列,并可能导致意想不到的交错代码。
  • 同步地调用异步的回调函数可能导致栈溢出或错误地处理异常。
  • 使用异步的API,比如setTimeout函数来调度异步回调函数,使其运行于另一个回合。

使用promise模式清洁异步逻辑

构建异步API的一种流行的替代方式是使用promise(有时也被称为deferred或future)模式。

基于promise的API不接收回调函数作为参数,相反,它返回一个promise对象,该对象通过其自身的then方法接收回调函数。

var p = downloadP('file.txt');

p.then(function(file) {
    console.log('file: ' + file);
});

promise的力量在于它们的组合性。传递给then的回调函数不仅产生影响,也可以产生结果。通过回调函数返回一个值,可以构造一个新的promise。

var fileP = downloadP('file.txt');

var lengthP = fileP.then(function(file) {
    return file.length;
});

lengthP.then(function(length) {
    console.log('length: ' + length);
});

promise可以非常容易地构造一个实用程序来拼接多个promise的结果。

var filesP = join(downloadP('file1.txt'), downloadP('file2.txt'), downloadP('file3.txt'));

filesP.then(function(files) {
    console.log('file1: ' + files[0]);
    console.log('file2: ' + files[1]);
    console.log('file3: ' + files[2]);
});

promise库也经常提供一个叫做when的工具函数。

var fileP1 = downloadP('file1.txt'), 
    fileP2 = downloadP('file2.txt'), 
    fileP3 = downloadP('file3.txt');

when([fileP1, fileP2, fileP3], function(files) {
    console.log('file1: ' + files[0]);
    console.log('file2: ' + files[1]);
    console.log('file3: ' + files[2]);
});

使promise成为卓越的抽象层级的部分原因是通过then方法的返回值来联系结果,或者通过工具函数如join来构成promise,而不是在并行的回调函数间共享数据结构。这本质上是安全的,因为它避免了数据竞争。

有时故意创建某种类的数据竞争是有用的。promise为此提供了一个很好的机制。例如,一个应用程序可能需要尝试从多个不同的服务器上同时下载同一份文件,而选择最先完成的那个文件。

var fileP = select(downloadP('http://example1.com/file.txt'), 
                    downloadP('http://example1.com/file.txt'),
                    downloadP('http://example1.com/file.txt'));

fileP.then(function(file) {
    console.log('file: ' + file);
});

select函数的另一个用途是提供超时来终止长时间的操作。

var fileP = select(downloadP('file.txt'), timeoutErrorP(2000));

fileP.then(function(file) {
    console.log('file: ' + file);
}, function(error) {
    console.log('I/O error or timeout: ' + error);
});

提示:

  • promise代表最终值,即并行完成时最终产生的结果。
  • 使用promise组合不同的并行操作。
  • 使用promise模式的API避免数据竞争。
  • 在要求有意的竞争条件时使用select(也被称为choose)。

3santiago3
113 声望2 粉丝