Los modos y la espera: compartir, rendirse y ceder el paso
Bloqueos compartidos para que los lectores no se serialicen, una cola estricta que evita la inanición pero forma caravanas, y las tres formas de no quedarse esperando cuando el recurso está ocupado.
Hasta aquí el bloqueo ha sido un interruptor: o lo tienes o esperas. Esa lectura basta para elegir líder, donde la espera indefinida es justamente lo que quieres, pero se queda muy corta en cuanto empiezas a proteger recursos de grano fino dentro de tu aplicación. Un índice que muchas pestañas consultan y una sola reconstruye, una migración que debe ocurrir exactamente una vez, una compactación de fondo que no debería hacer esperar a nadie. Para eso la API tiene un modo compartido, tres maneras distintas de no esperar y una regla de encolado que conviene conocer al detalle, porque explica comportamientos que de otro modo parecen caprichosos.
- Usar el modo compartido para que las lecturas concurrentes no se serialicen sin motivo.
- Entender la regla estricta de la cola y las dos consecuencias opuestas que produce.
- Elegir entre rendirse de inmediato, esperar con plazo o robar el bloqueo, y conocer el riesgo de cada opción.
- Impedir que una pestaña de segundo plano con temporizadores ralentizados haga esperar a la que el usuario mira.
Compartido: muchos lectores a la vez
El modo compartido concede el mismo nombre a varios solicitantes simultáneos siempre que ninguno de ellos lo pida en exclusiva. Es la pareja clásica de lectores y escritor que llevan décadas ofreciendo los sistemas operativos, y responde a la misma observación: leer no daña, así que serializar las lecturas es un coste que se paga sin recibir nada.
// Muchas pestanas pueden estar aqui dentro a la vez
export function leerIndice() {
return navigator.locks.request('indice-v1', { mode: 'shared' }, async () => {
return await cargarDesdeAlmacenamiento();
});
}
// Esta espera a que no quede ningun lector dentro
export function reconstruirIndice() {
return navigator.locks.request('indice-v1', { mode: 'exclusive' }, async () => {
await borrarIndice();
await regenerarIndice();
});
}
El contrato que estableces al escribir esas dos funciones es una afirmación fuerte sobre tu código, y conviene enunciarla en voz alta: mientras un lector esté dentro de su callback, el recurso no cambiará. Todo el valor del modo compartido depende de que esa afirmación sea cierta, y deja de serlo en cuanto exista una sola ruta que modifique el recurso sin pedir el bloqueo exclusivo. El navegador no lo comprobará por ti; el modo compartido no protege nada, se limita a permitir la convivencia que tú has declarado segura.
Y hay un caso donde el modo compartido es la herramienta perfecta y casi nadie lo aplica: proteger un recurso frente a operaciones destructivas globales. Piensa en el borrado de la base para cerrar sesión, en una migración de esquema o en una compactación que reescribe los ficheros. Si toda operación normal toma el bloqueo en compartido y solo esas tres lo toman en exclusiva, obtienes gratis la garantía de que la destrucción nunca ocurrirá con alguien dentro, sin penalizar en absoluto el camino habitual.
La cola es estricta, y por eso es justa
Ahora la regla que gobierna las concesiones, que es más rígida de lo que la gente supone. Para que una petición se conceda tienen que cumplirse dos condiciones a la vez: que ningún bloqueo tomado con ese nombre entre en conflicto con ella, y que no haya ninguna petición anterior en la cola con ese mismo nombre. La segunda condición es la interesante, porque significa que la cola se respeta en orden de llegada sin adelantamientos, aunque el adelantamiento fuera inofensivo.
flowchart TB A[Lector uno modo compartido] --> Q[Cola del nombre indice] B[Lector dos modo compartido] --> Q C[Escritor modo exclusivo] --> Q D[Lector tres modo compartido] --> Q Q --> G1[Uno y dos entran juntos porque no hay conflicto] G1 --> G2[El escritor espera a que ambos salgan] G2 --> G3[El lector tres espera detras del escritor aunque podria convivir con uno y dos] style G1 fill:#a6e3a1,color:#11111b style G2 fill:#f9e2af,color:#11111b style G3 fill:#f38ba8,color:#11111b
De esa rigidez salen dos consecuencias opuestas y las dos importan. La buena es que el escritor nunca muere de hambre: si los adelantamientos estuvieran permitidos, un flujo continuo de lectores podría entrar sin parar y el escritor no encontraría jamás un hueco vacío, que es la patología clásica de los bloqueos de lectura y escritura mal diseñados. La cola estricta lo impide por construcción, y ese es exactamente el motivo por el que la especificación la definió así.
La mala es la caravana: en cuanto un escritor entra en la cola, todos los lectores posteriores se detienen detrás de él, incluso los que podrían haber convivido perfectamente con los que ya están dentro. Un solo escritor lento convierte un recurso de lectura concurrente en un recurso serializado, y el síntoma que verás es desconcertante: lecturas que normalmente tardan un milisegundo y de pronto tardan medio segundo, sin que nada haya cambiado en el código de lectura.
La caravana casi nunca la produce un escritor que haga mucho trabajo, sino uno que haga poco trabajo y mucha espera. Basta una petición de red dentro del callback, un temporizador para reintentar, una espera a que el usuario confirme algo en un diálogo, o incluso una simple cadena de operaciones asíncronas sobre otra API de almacenamiento. Mientras esa promesa esté pendiente el bloqueo sigue tomado y la cola sigue creciendo, y si además la pestaña que lo sostiene se va a segundo plano, sus temporizadores se ralentizan y lo que iba a durar cincuenta milisegundos dura un minuto. La regla es tajante: prepara los datos fuera, entra, aplica el cambio, sal. Nada de red, nada de esperas por el usuario y nada de temporizadores dentro de un bloqueo.
Tres formas de no esperar
La espera indefinida es el comportamiento por omisión y es correcta para el liderazgo, donde esperar es precisamente el trabajo del seguidor. Para casi todo lo demás quieres otra cosa, y la API ofrece tres alternativas que no se pueden combinar entre sí.
Rendirse de inmediato
Con ifAvailable en cierto, si el bloqueo no se puede conceder ahora mismo tu callback se ejecuta igualmente pero recibe un valor nulo. No es un error y no hay excepción: si no compruebas ese nulo, tu código creerá que tiene el bloqueo y no lo tendrá.
Esperar con plazo
La opción signal acepta una señal de aborto. Si se dispara antes de la concesión, la promesa se rechaza con un error de aborto. Es la única forma de poner un tiempo máximo, porque la API no trae ninguno de fábrica.
Robar el bloqueo
Con steal en cierto el navegador expulsa a quien lo tuviera y te lo concede al instante. Existe para recuperarse de un titular que sabes roto, y su efecto secundario es exactamente lo que el bloqueo debía impedir.
Y la espera limpia
Sin ninguna opción, esperas para siempre y en orden. Es lo correcto cuando la espera no bloquea ninguna interfaz: elección de líder, tareas de fondo, colas de trabajo que nadie está mirando.
// Rendirse: perfecto para trabajo opcional que otro ya podria estar haciendo
await navigator.locks.request('compactar', { ifAvailable: true }, async (lock) => {
if (!lock) return; // ocupado: hoy no nos toca, y no pasa nada
await compactar();
});
// Plazo: para cuando hay una interfaz esperando la respuesta
try {
await navigator.locks.request(
'migracion',
{ signal: AbortSignal.timeout(3000) },
async () => aplicarMigracion(),
);
} catch (e) {
if (e.name === 'AbortError') mostrarAviso('La aplicacion esta ocupada. Reintenta.');
else throw e;
}
Sobre el plazo hay una precisión que evita un error de razonamiento muy extendido: la señal solo cancela la espera, nunca la ejecución. Si el bloqueo llega a concederse antes de que la señal se dispare, abortar después no interrumpe tu callback ni libera nada; el bloqueo seguirá tomado hasta que tu promesa se asiente. La señal responde a “cuánto estoy dispuesto a esperar”, no a “cuánto puede durar la operación”, y esa segunda pregunta la tienes que resolver dentro de tu propio código.
Y sobre el robo, la advertencia se resume en una frase: expulsar al titular no detiene su código. La promesa de la petición robada se rechaza, sí, pero el callback que estaba a medias sigue ejecutándose hasta el final, ajeno a todo, posiblemente con un fichero abierto y una escritura empezada. Durante ese intervalo hay dos contextos actuando como dueños del mismo recurso, que es exactamente el estado que el bloqueo existía para prevenir. Usarlo sobre un fichero de OPFS es la receta directa de la corrupción; su único uso defendible es liberar un nombre de coordinación puramente lógico cuando has diagnosticado con certeza que el titular está roto.
Que la de fondo no bloquee a la activa
Queda el problema práctico que más veces se encuentra quien despliega esto: el usuario tiene tu aplicación en dos pestañas, trabaja en una y la otra lleva horas oculta. La oculta sigue viva, sigue compitiendo por los mismos nombres y sus temporizadores están ralentizados por el navegador hasta cadencias de un minuto o más. Si esa pestaña toma un bloqueo y dentro espera a un temporizador, la pestaña que el usuario mira se queda parada, y la culpa parecerá del código de la pestaña activa.
La solución no es mover el liderazgo cada vez que el usuario cambia de pestaña, que sería carísimo. Es separar los nombres por lo que protegen y darle a lo que no corre una forma de ceder el paso. El patrón es un bloqueo de cortesía: la pestaña activa lo toma antes de trabajar, y la tarea de fondo lo consulta entre tramos y se retira si está ocupado.
// La pestana activa avisa de que necesita el recurso
async function operacionDelUsuario(fn) {
return navigator.locks.request('prioridad', { mode: 'exclusive' }, async () => {
return navigator.locks.request('datos', { mode: 'exclusive' }, fn);
});
}
// La tarea de fondo trabaja a trozos y cede en cuanto alguien la reclama
async function compactarPorTramos(tramos) {
for (const tramo of tramos) {
const libre = await navigator.locks.request(
'prioridad',
{ ifAvailable: true },
async (lock) => lock !== null,
);
if (!libre) return 'CEDIDO'; // hay alguien delante: nos apartamos
await navigator.locks.request('datos', { mode: 'exclusive' }, () => aplicar(tramo));
}
return 'COMPLETADO';
}
Lo que hace ese diseño es introducir una prioridad que la API no ofrece, porque su cola es estrictamente de orden de llegada y no conoce el concepto de urgencia. El nombre prioridad no protege ningún dato: es puro señalizador, un sitio donde la pestaña activa puede afirmar “estoy trabajando” y donde la de fondo puede mirar sin arriesgarse a esperar. Y como el trabajo de fondo está troceado, cada tramo dura poco y el peor caso de espera para el usuario es un solo tramo y no la tarea entera.
El primero es trocear cualquier trabajo largo y tomar el bloqueo por tramo en lugar de una sola vez, porque lo que hace daño no es la duración total sino la duración de cada retención. El segundo es pedir siempre los nombres en el mismo orden en todo el código: dos rutas que tomen datos y prioridad en orden inverso producen un abrazo mortal entre pestañas idéntico al de un manual de sistemas operativos, y aquí no hay ningún detector que lo diagnostique. El tercero es que toda tarea que no tenga a nadie esperándola use ifAvailable, porque una tarea de fondo que se rinde y vuelve dentro de un minuto no cuesta nada, y una que espera en la cola puede estar sosteniendo el turno de alguien que sí importa.
Aquí hay una decisión de arquitectura que se toma casi siempre por accidente y que determina el comportamiento de la aplicación bajo carga más que cualquier otra elección técnica del nivel. Los nombres de bloqueo son cadenas libres, no corresponden a nada real y el navegador no valida ninguno: eso significa que la partición de tu estado en unidades de exclusión mutua la estás definiendo tú, con la misma libertad y la misma responsabilidad con que un motor de base de datos elige entre bloquear la tabla entera, la página o la fila. Un único nombre para todo es el equivalente a un bloqueo global del intérprete: nunca te dará una carrera de datos y nunca te dará concurrencia, y el día que el trabajo crezca no habrá optimización posible porque el cuello de botella es el diseño. Un nombre por documento, por colección o por agregado te da paralelismo real a cambio de que ahora existan varios bloqueos, y en cuanto existen varios reaparece en tu navegador la teoría completa que creías reservada a las bases de datos: orden global de adquisición para no abrazarte mortalmente, granularidad que hay que ajustar con medidas y no con intuición, escalada de bloqueos cuando una operación toca demasiados a la vez, y la caravana de la cola estricta esperándote en cuanto un escritor se demore. La lección que quiero que te lleves no es cuál de las dos opciones elegir, porque depende por completo de tu carga; es que elegir es inevitable, que el silencio también es una elección, y que la mayoría de las aplicaciones acaban con un solo nombre no porque alguien lo decidiera tras medir, sino porque nadie planteó la pregunta. Escribe en algún sitio de tu repositorio la lista de nombres de bloqueo que usa tu aplicación, qué protege cada uno y en qué orden deben pedirse. Ese documento de veinte líneas es tu modelo de concurrencia, es lo primero que agradecerá quien depure un abrazo mortal a las tres de la mañana, y no existe en casi ningún proyecto.
- Monta un recurso con lectores compartidos y un escritor exclusivo, y comprueba con marcas de tiempo que varios lectores entran a la vez.
- Encola un escritor lento y demuestra la caravana: mide cuánto tarda un lector que llega después de él y compáralo con el caso sin escritor.
- Provoca la ralentización real dejando una pestaña oculta que sostenga un bloqueo mientras espera a un temporizador, y observa el efecto en la pestaña activa.
- Implementa el bloqueo de cortesía y la tarea de fondo troceada, y verifica que la operación del usuario nunca espera más que un tramo.
- Crea a propósito un abrazo mortal pidiendo dos nombres en orden inverso desde dos pestañas, y comprueba que no aparece ningún error en la consola.
- Prueba
stealsobre un nombre de coordinación y observa que el callback de la víctima sigue ejecutándose después de haber sido expulsada.